├── .formatter.exs ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── CHANGELOG.md ├── README.md ├── docker-compose.yml ├── lib └── broadway_rabbitmq │ ├── amqp_client.ex │ ├── backoff.ex │ ├── channel_pool.ex │ ├── producer.ex │ └── rabbitmq_client.ex ├── mix.exs ├── mix.lock └── test ├── broadway_rabbitmq ├── ampq_client_test.exs ├── backoff_test.exs └── producer_test.exs └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-20.04 12 | env: 13 | MIX_ENV: test 14 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 15 | strategy: 16 | fail-fast: false 17 | matrix: 18 | include: 19 | # Hard to test the oldest-supported version because rabbit_common and friends 20 | # are a bit hard to get compatibility right for. However, we don't really need 21 | # to do that here. 22 | - elixir: "1.16" 23 | otp: "26.2" 24 | 25 | # Latest-supported Elixir/OTP versions. 26 | - elixir: "1.17.3" 27 | otp: "27.1" 28 | lint: lint 29 | coverage: coverage 30 | steps: 31 | - name: Clone the repository 32 | uses: actions/checkout@v4 33 | 34 | - name: Start Docker 35 | run: docker compose up --detach 36 | 37 | - name: Install Erlang/OTP and Elixir 38 | uses: erlef/setup-beam@v1 39 | with: 40 | otp-version: ${{ matrix.otp }} 41 | elixir-version: ${{ matrix.elixir }} 42 | 43 | - name: Cache Mix dependencies 44 | uses: actions/cache@v4 45 | with: 46 | path: | 47 | deps 48 | _build 49 | key: | 50 | ${{ runner.os }}-mix-${{ matrix.elixir }}-${{ matrix.otp }}-${{ hashFiles('**/mix.lock') }} 51 | restore-keys: | 52 | ${{ runner.os }}-mix-${{ matrix.elixir }}-${{ matrix.otp }}- 53 | 54 | - name: Fetch Mix dependencies 55 | run: mix deps.get 56 | 57 | - name: Run the formatter 58 | run: mix format --check-formatted 59 | if: ${{ matrix.lint }} 60 | 61 | - name: Check for unused dependencies 62 | run: mix deps.unlock --check-unused 63 | if: ${{ matrix.lint }} 64 | 65 | - name: Compile code and check for warnings 66 | run: mix compile --warnings-as-errors 67 | if: ${{ matrix.lint }} 68 | 69 | - name: Run tests 70 | run: mix test 71 | if: ${{ !matrix.coverage }} 72 | 73 | - name: Run tests with coverage 74 | run: mix coveralls.github 75 | if: ${{ matrix.coverage }} 76 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | broadway_rabbitmq-*.tar 24 | 25 | /log/ 26 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v0.8.2 (2024-11-12) 4 | 5 | * Add support to AMQP 4.0 6 | 7 | ## v0.8.1 (2023-11-02) 8 | 9 | * Fix compilation warning around the use of `Logger.warn/2`. 10 | * Fix a bug with validating a behaviour when implementing multiple behaviours. 11 | * Fix closing connection from channel pool (see [#129](https://github.com/dashbitco/broadway_rabbitmq/pull/129)). 12 | 13 | ## v0.8.0 (2023-04-25) 14 | 15 | * Add the `BroadwayRabbitMQ.ChannelPool` behaviour. 16 | * Add the `[:broadway_rabbitmq, :amqp, :connection_failure]` Telemetry event. 17 | * Bump Elixir requirement to 1.8+. 18 | * Allow nimble_options 1.0+. 19 | 20 | ## v0.7.2 (2022-01-12) 21 | 22 | * Support nimble_options 0.4.0 alongside 0.3.x 23 | 24 | ## v0.7.1 (2021-11-25) 25 | 26 | * Add support to AMQP 3.0 27 | 28 | ## v0.7.0 (2021-08-30) 29 | 30 | * Add support to AMQP 2.0 31 | * Require Broadway 1.0 32 | 33 | ## v0.6.5 (2020-12-11) 34 | 35 | * Add support for a few Telemetry events. See the "Telemetry" section 36 | in the docs for `BroadwayRabbitMQ.Producer`. 37 | 38 | * Add support for `:consume_options` when starting a 39 | `BroadwayRabbitMQ.Producer` to pass options down to `AMQP.Basic.consume/4`. 40 | 41 | ## v0.6.4 (2020-11-23) 42 | 43 | * Bump nimble_options dependency to 0.3.5 which fixes some deprecation 44 | warnings. 45 | 46 | * Fix a few potential RabbitMQ issues like possible connection leaking (see 47 | [#83](https://github.com/dashbitco/broadway_rabbitmq/pull/83)). 48 | 49 | ## v0.6.3 (2020-11-19) 50 | 51 | * Start using nimble_options for validation. This has no practical 52 | consequences on the API but introduces a new dependency in broadway_rabbitmq 53 | (which was already used by Broadway). 54 | * Raise if acking messages fails. See the discussion in 55 | [dashbitco/broadway#208](https://github.com/dashbitco/broadway/issues/208). 56 | 57 | ## v0.6.2 (2020-10-24) 58 | 59 | * Deprecate use of a default `:on_failure` option. 60 | * Expose always-present `:amqp_channel` metadata containing the `AMQP.Channel` 61 | struct. 62 | 63 | ## v0.6.1 (2020-06-05) 64 | 65 | * Add support for the `:after_connect` option. 66 | * Add `auth_mechanisms` to the supported connection options for RabbitMQ. 67 | * Support passing in an AMQP connection name. 68 | * Update Broadway requirement to `~> 0.6.0` (it was exactly `0.6.0`) before. 69 | 70 | ## v0.6.0 (2020-02-19) 71 | 72 | * Update Broadway requirement to 0.6.0. 73 | * Re-initialize client options on every reconnect. This means that the `:merge_options` 74 | function is called on every reconnect, allowing to do things such as round-robin 75 | on a list of RabbitMQ URLs. 76 | * Remove support for the deprecated `:requeue` option. Use `:on_success`/`:on_failure` 77 | instead. 78 | * Improve logging on RabbitMQ disconnections and reconnections. 79 | 80 | ## v0.5.0 (2019-11-04) 81 | 82 | * Add support for configuring acking behaviour using `:on_success` and `:on_failure` options 83 | * Add support for declare options `:no_wait` and `:arguments` 84 | * Handle `:auth_failure`, `:unknown_host` and `:socket_closed_unexpectedly` errors 85 | * Add support for a function as the `:connection` 86 | * Add support for `:merge_options` option 87 | * Update to Broadway v0.5.0 88 | 89 | ## v0.4.0 (2019-08-06) 90 | 91 | * Add `:declare` and `:bindings` options to producers 92 | * Handle consumer cancellation by reconnecting 93 | 94 | ## v0.3.0 (2019-06-06) 95 | 96 | * Allow overriding `:buffer_size` and `:buffer_keep` 97 | * Make `:buffer_size` required if `:prefetch_count` is set to `0` 98 | * Allow passing RabbitMQ connection options via an AMQP URI 99 | 100 | ## v0.2.0 (2019-05-09) 101 | 102 | * New option `:metadata` that allows users to select which metadata should be retrieved 103 | and appended to the message struct 104 | * New option `:requeue` that allows users to define a strategy for requeuing failed messages 105 | 106 | ## v0.1.0 (2019-04-09) 107 | 108 | * Initial release 109 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # BroadwayRabbitMQ 2 | 3 | [![hex.pm badge](https://img.shields.io/badge/Package%20on%20hex.pm-informational)](https://hex.pm/packages/broadway_rabbitmq) 4 | [![Documentation badge](https://img.shields.io/badge/Documentation-ff69b4)][docs] 5 | [![CI](https://github.com/dashbitco/broadway_rabbitmq/actions/workflows/ci.yml/badge.svg)](https://github.com/dashbitco/broadway_rabbitmq/actions/workflows/ci.yml) 6 | [![Coverage Status](https://coveralls.io/repos/github/dashbitco/broadway_rabbitmq/badge.svg?branch=master)](https://coveralls.io/github/dashbitco/broadway_rabbitmq?branch=master) 7 | 8 | A RabbitMQ connector for [Broadway](https://github.com/dashbitco/broadway). 9 | 10 | Documentation can be found at [https://hexdocs.pm/broadway_rabbitmq][docs]. 11 | For more details on using Broadway with RabbitMQ, please see the 12 | [RabbitMQ Guide](https://hexdocs.pm/broadway/rabbitmq.html). 13 | 14 | ## Installation 15 | 16 | Add `:broadway_rabbitmq` to the list of dependencies in `mix.exs`: 17 | 18 | ```elixir 19 | def deps do 20 | [ 21 | {:broadway_rabbitmq, "~> 0.7.0"} 22 | ] 23 | end 24 | ``` 25 | 26 | ## Usage 27 | 28 | Configure Broadway with one or more producers using `BroadwayRabbitMQ.Producer`: 29 | 30 | ```elixir 31 | defmodule MyBroadway do 32 | use Broadway 33 | 34 | def start_link(_opts) do 35 | Broadway.start_link(__MODULE__, 36 | name: __MODULE__, 37 | producer: [ 38 | module: {BroadwayRabbitMQ.Producer, 39 | queue: "my_queue", 40 | }, 41 | concurrency: 1 42 | ], 43 | processors: [ 44 | default: [ 45 | concurrency: 10 46 | ] 47 | ] 48 | ) 49 | end 50 | 51 | def handle_message(_, message, _) do 52 | IO.inspect(message.data, label: "Got message") 53 | message 54 | end 55 | end 56 | ``` 57 | 58 | ## Contributing 59 | 60 | To run tests that don't need any dependency, use: 61 | 62 | ```bash 63 | mix test --exclude integration 64 | ``` 65 | 66 | To run integration tests, you'll need RabbitMQ running on your `localhost:5672`. 67 | For simplicity, this repository includes a 68 | [`docker-compose.yml`](./docker-compose.yml) file that you can use to run 69 | RabbitMQ via [Docker Compose](https://docs.docker.com/compose/). If you have 70 | [Docker](https://www.docker.com) installed, you can run: 71 | 72 | ```bash 73 | docker-compose up -d 74 | ``` 75 | 76 | Then, you can run `mix test`. 77 | 78 | ## License 79 | 80 | Copyright 2019 Plataformatec\ 81 | Copyright 2020 Dashbit 82 | 83 | Licensed under the Apache License, Version 2.0 (the "License"); 84 | you may not use this file except in compliance with the License. 85 | You may obtain a copy of the License at 86 | 87 | http://www.apache.org/licenses/LICENSE-2.0 88 | 89 | Unless required by applicable law or agreed to in writing, software 90 | distributed under the License is distributed on an "AS IS" BASIS, 91 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 92 | See the License for the specific language governing permissions and 93 | limitations under the License. 94 | 95 | [docs]: https://hexdocs.pm/broadway_rabbitmq 96 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | rabbitmq: 3 | image: "rabbitmq:3.11.9-alpine" 4 | ports: 5 | - "5672:5672" 6 | -------------------------------------------------------------------------------- /lib/broadway_rabbitmq/amqp_client.ex: -------------------------------------------------------------------------------- 1 | defmodule BroadwayRabbitMQ.AmqpClient do 2 | @moduledoc false 3 | 4 | alias AMQP.{ 5 | Connection, 6 | Channel, 7 | Basic, 8 | Queue 9 | } 10 | 11 | require Logger 12 | 13 | @behaviour BroadwayRabbitMQ.RabbitmqClient 14 | 15 | @connection_opts_schema [ 16 | username: [type: :any], 17 | password: [type: :any], 18 | virtual_host: [type: :any], 19 | host: [type: :any], 20 | port: [type: :any], 21 | channel_max: [type: :any], 22 | frame_max: [type: :any], 23 | heartbeat: [type: :any], 24 | connection_timeout: [type: :any], 25 | ssl_options: [type: :any], 26 | client_properties: [type: :any], 27 | socket_options: [type: :any], 28 | auth_mechanisms: [type: :any] 29 | ] 30 | 31 | @binding_opts_schema [ 32 | routing_key: [type: :any], 33 | arguments: [type: :any] 34 | ] 35 | 36 | @opts_schema [ 37 | queue: [ 38 | type: :string, 39 | required: true, 40 | doc: """ 41 | The name of the queue. If `""`, then the queue name will 42 | be autogenerated by the server but for this to work you have to declare 43 | the queue through the `:declare` option. 44 | """ 45 | ], 46 | connection: [ 47 | type: 48 | {:or, 49 | [ 50 | {:custom, __MODULE__, :__validate_amqp_uri__, []}, 51 | {:custom, __MODULE__, :__validate_custom_pool__, []}, 52 | keyword_list: @connection_opts_schema 53 | ]}, 54 | default: [], 55 | doc: """ 56 | Defines an AMQP URI (a string), a custom pool, or a set of options used by 57 | the RabbitMQ client to open the connection with the RabbitMQ broker. 58 | To use a custom pool, pass a `{:custom_pool, module, args}` tuple, see 59 | `BroadwayRabbitMQ.ChannelPool` for more information. If passing an AMQP URI 60 | or a list of options, this producer manages the AMQP connection instead. 61 | See `AMQP.Connection.open/1` for the full list of connection options. 62 | """ 63 | ], 64 | qos: [ 65 | type: :keyword_list, 66 | keys: [ 67 | prefetch_size: [type: :non_neg_integer], 68 | prefetch_count: [type: :non_neg_integer, default: 50] 69 | ], 70 | default: [], 71 | doc: """ 72 | Defines a set of prefetch options used by the RabbitMQ client. 73 | See `AMQP.Basic.qos/2` for the full list of options. Note that the 74 | `:global` option is not supported by Broadway since each producer holds only one 75 | channel per connection. 76 | """ 77 | ], 78 | name: [ 79 | type: {:or, [:string, {:in, [:undefined]}]}, 80 | default: :undefined, 81 | doc: """ 82 | The name of the AMQP connection to use. 83 | """ 84 | ], 85 | metadata: [ 86 | type: {:list, :atom}, 87 | default: [], 88 | doc: """ 89 | The list of AMQP metadata fields to copy (default: `[]`). Note 90 | that every `Broadway.Message` contains an `:amqp_channel` in its `metadata` field. 91 | See the "Metadata" section. 92 | """ 93 | ], 94 | declare: [ 95 | type: :keyword_list, 96 | keys: [ 97 | durable: [type: :any, doc: false], 98 | auto_delete: [type: :any, doc: false], 99 | exclusive: [type: :any, doc: false], 100 | passive: [type: :any, doc: false], 101 | arguments: [type: :any, doc: false] 102 | ], 103 | doc: """ 104 | A list of options used to declare the `:queue`. The 105 | queue is only declared (and possibly created if not already there) if this 106 | option is present and not `nil`. Note that if you use `""` as the queue 107 | name (which means that the queue name will be autogenerated on the server), 108 | then every producer stage will declare a different queue. If you want all 109 | producer stages to consume from the same queue, use a specific queue name. 110 | You can still declare the same queue as many times as you want because 111 | queue creation is idempotent (as long as you don't use the `passive: true` 112 | option). For the available options, see `AMQP.Queue.declare/3`, `:nowait` is not supported. 113 | """ 114 | ], 115 | bindings: [ 116 | type: {:list, {:custom, __MODULE__, :__validate_binding__, []}}, 117 | default: [], 118 | doc: """ 119 | A list of bindings for the `:queue`. This option 120 | allows you to bind the queue to one or more exchanges. Each binding is a tuple 121 | `{exchange_name, binding_options}` where so that the queue will be bound 122 | to `exchange_name` through `AMQP.Queue.bind/4` using `binding_options` as 123 | the options. Bindings are idempotent so you can bind the same queue to the 124 | same exchange multiple times. 125 | """ 126 | ], 127 | merge_options: [ 128 | type: {:fun, 1}, 129 | doc: """ 130 | A function that takes the index of the producer in the 131 | Broadway topology and returns a keyword list of options. The returned options 132 | are merged with the other options given to the producer. This option is useful 133 | to dynamically change options based on the index of the producer. For example, 134 | you can use this option to "shard" load between a few queues where a subset of 135 | the producer stages is connected to each queue, or to connect producers to 136 | different RabbitMQ nodes (for example through partitioning). Note that the options 137 | are evaluated every time a connection is established (for example, in case 138 | of disconnections). This means that you can also use this option to choose 139 | different options on every reconnections. This can be particularly useful 140 | if you have multiple RabbitMQ URLs: in that case, you can reconnect to a different 141 | URL every time you reconnect to RabbitMQ, which avoids the case where the 142 | producer tries to always reconnect to a URL that is down. 143 | """ 144 | ], 145 | after_connect: [ 146 | type: {:fun, 1}, 147 | doc: """ 148 | A function that takes the AMQP channel that the producer 149 | is connected to and can run arbitrary setup. This is useful for declaring 150 | complex RabbitMQ topologies with possibly multiple queues, bindings, or 151 | exchanges. RabbitMQ declarations are generally idempotent so running this 152 | function from all producer stages after every time they connect is likely 153 | fine. This function can return `:ok` if everything went well or `{:error, reason}`. 154 | In the error case then the producer will consider the connection failed and 155 | will try to reconnect later (same behavior as when the connection drops, for example). 156 | This function is run **before** the declaring and binding queues according to 157 | the `:declare` and `:bindings` options (described above). 158 | """ 159 | ], 160 | consume_options: [ 161 | type: :keyword_list, 162 | default: [], 163 | doc: """ 164 | Options passed down to `AMQP.Basic.consume/4`. Not all options supported by 165 | `AMQP.Basic.consume/4` are available here as some options would conflict with 166 | the internal implementation of this producer. 167 | """, 168 | keys: [ 169 | consumer_tag: [type: :string], 170 | no_local: [type: :boolean], 171 | no_ack: [type: :boolean], 172 | exclusive: [type: :boolean], 173 | arguments: [type: :any] 174 | ] 175 | ], 176 | broadway: [type: :any, doc: false] 177 | ] 178 | 179 | @doc false 180 | def __opts_schema__, do: @opts_schema 181 | 182 | @impl true 183 | def init(opts) do 184 | with {:ok, opts} <- validate_merge_opts(opts), 185 | {:ok, opts} <- NimbleOptions.validate(opts, @opts_schema), 186 | :ok <- validate_declare_opts(opts[:declare], opts[:queue]) do 187 | {:ok, 188 | %{ 189 | connection: Keyword.fetch!(opts, :connection), 190 | queue: Keyword.fetch!(opts, :queue), 191 | name: Keyword.fetch!(opts, :name), 192 | declare_opts: Keyword.get(opts, :declare, nil), 193 | bindings: Keyword.fetch!(opts, :bindings), 194 | qos: Keyword.fetch!(opts, :qos), 195 | metadata: Keyword.fetch!(opts, :metadata), 196 | consume_options: Keyword.fetch!(opts, :consume_options), 197 | after_connect: Keyword.get(opts, :after_connect, fn _channel -> :ok end) 198 | }} 199 | else 200 | {:error, %NimbleOptions.ValidationError{} = error} -> {:error, Exception.message(error)} 201 | {:error, message} when is_binary(message) -> {:error, message} 202 | end 203 | end 204 | 205 | # This function should return "{:ok, channel}" if successful. If failing to setup a channel, a 206 | # connection, or if some network error happens at any point, this should close the connection it 207 | # opened. 208 | @impl true 209 | def setup_channel(config) do 210 | case get_channel(config) do 211 | {:ok, channel} -> 212 | with :ok <- call_after_connect(config, channel), 213 | :ok <- Basic.qos(channel, config.qos), 214 | {:ok, queue} <- maybe_declare_queue(channel, config.queue, config.declare_opts), 215 | :ok <- maybe_bind_queue(channel, queue, config.bindings) do 216 | {:ok, channel} 217 | else 218 | {:error, reason} -> 219 | # We don't terminate the caller process when something fails, but just reconnect 220 | # later. So if opening the connection works, but any other step fails (like opening 221 | # the channel), we need to close the connection, or otherwise we would leave the 222 | # connection open and leak it. In amqp_client, closing the connection also closes 223 | # everything related to it (like the channel, so we're good). 224 | close_channel(config, channel) 225 | {:error, reason} 226 | end 227 | 228 | error -> 229 | error 230 | end 231 | catch 232 | :exit, {:timeout, {:gen_server, :call, [amqp_conn_pid, :connect, timeout]}} 233 | when is_integer(timeout) -> 234 | # Make absolutely sure that this connection doesn't get established *after* the gen_server 235 | # call timeout triggers and becomes a zombie connection. 236 | true = Process.exit(amqp_conn_pid, :kill) 237 | {:error, :timeout} 238 | end 239 | 240 | defp get_channel(%{connection: {:custom_pool, module, args}}) do 241 | case module.checkout_channel(args) do 242 | {:ok, channel} -> 243 | true = Process.link(channel.pid) 244 | {:ok, channel} 245 | 246 | # TODO: use is_exception/1 when we depend on Elixir 1.11+ 247 | {:error, %{__exception__: true} = exception} -> 248 | {:error, exception} 249 | 250 | other -> 251 | raise """ 252 | expected #{Exception.format_mfa(module, :checkout_channel, 1)} to \ 253 | return {:ok, AMQP.Channel.t()} or {:error, exception}, got: \ 254 | #{inspect(other)}\ 255 | """ 256 | end 257 | end 258 | 259 | defp get_channel(config) do 260 | with {:ok, conn} <- open_connection_instrumented(config), 261 | # We need to link so that if our process crashes, the AMQP connection will go 262 | # down. We're trapping exits in the producer anyways so on our end this looks 263 | # like a monitor, pretty much. 264 | true = Process.link(conn.pid), 265 | {{:ok, chan}, _conn} <- {Channel.open(conn), conn} do 266 | {:ok, chan} 267 | else 268 | {:error, reason} -> 269 | {:error, reason} 270 | 271 | {{:error, reason}, conn} -> 272 | _ = Connection.close(conn) 273 | {:error, reason} 274 | end 275 | end 276 | 277 | defp close_channel(%{connection: {:custom_pool, module, args}}, channel) do 278 | case module.checkin_channel(args, channel) do 279 | :ok -> 280 | :ok 281 | 282 | # TODO: use is_exception/1 when we depend on Elixir 1.11+ 283 | {:error, %{__exception__: true} = exception} -> 284 | Channel.close(channel) 285 | {:error, exception} 286 | 287 | other -> 288 | Channel.close(channel) 289 | 290 | raise """ 291 | expected #{Exception.format_mfa(module, :checkin_channel, 1)} to \ 292 | return :ok or {:error, exception}, got: \ 293 | #{inspect(other)}\ 294 | """ 295 | end 296 | end 297 | 298 | defp close_channel(_config, channel) do 299 | Channel.close(channel) 300 | Process.unlink(channel.conn.pid) 301 | Connection.close(channel.conn) 302 | end 303 | 304 | defp open_connection_instrumented(config) do 305 | {name, config} = Map.pop(config, :name, :undefined) 306 | telemetry_meta = %{connection: config.connection, connection_name: name} 307 | 308 | :telemetry.span([:broadway_rabbitmq, :amqp, :open_connection], telemetry_meta, fn -> 309 | {Connection.open(config.connection, name), telemetry_meta} 310 | end) 311 | end 312 | 313 | defp call_after_connect(config, channel) do 314 | case config.after_connect.(channel) do 315 | :ok -> 316 | :ok 317 | 318 | {:error, reason} -> 319 | {:error, reason} 320 | 321 | other -> 322 | close_channel(config, channel) 323 | raise "unexpected return value from the :after_connect function: #{inspect(other)}" 324 | end 325 | end 326 | 327 | defp maybe_declare_queue(_channel, queue, _declare_opts = nil) do 328 | {:ok, queue} 329 | end 330 | 331 | defp maybe_declare_queue(channel, queue, declare_opts) do 332 | with {:ok, %{queue: queue}} <- Queue.declare(channel, queue, declare_opts) do 333 | {:ok, queue} 334 | end 335 | end 336 | 337 | defp maybe_bind_queue(_channel, _queue, _bindings = []) do 338 | :ok 339 | end 340 | 341 | defp maybe_bind_queue(channel, queue, [{exchange, opts} | bindings]) do 342 | case Queue.bind(channel, queue, exchange, opts) do 343 | :ok -> maybe_bind_queue(channel, queue, bindings) 344 | {:error, reason} -> {:error, reason} 345 | end 346 | end 347 | 348 | @impl true 349 | def ack(channel, delivery_tag) do 350 | :telemetry.span([:broadway_rabbitmq, :amqp, :ack], _meta = %{}, fn -> 351 | try do 352 | Basic.ack(channel, delivery_tag) 353 | catch 354 | :exit, {:noproc, _} -> {{:error, :noproc}, _meta = %{}} 355 | else 356 | result -> {result, _meta = %{}} 357 | end 358 | end) 359 | end 360 | 361 | @impl true 362 | def reject(channel, delivery_tag, opts) do 363 | :telemetry.span([:broadway_rabbitmq, :amqp, :reject], %{requeue: opts[:requeue]}, fn -> 364 | try do 365 | Basic.reject(channel, delivery_tag, opts) 366 | catch 367 | :exit, {:noproc, _} -> {{:error, :noproc}, _meta = %{}} 368 | else 369 | result -> {result, _meta = %{}} 370 | end 371 | end) 372 | end 373 | 374 | @impl true 375 | def consume(channel, %{queue: queue, consume_options: consume_options} = _config) do 376 | {:ok, consumer_tag} = Basic.consume(channel, queue, _consumer_pid = self(), consume_options) 377 | consumer_tag 378 | end 379 | 380 | @impl true 381 | def cancel(channel, consumer_tag) do 382 | Basic.cancel(channel, consumer_tag) 383 | end 384 | 385 | @impl true 386 | def close_connection(config, channel) do 387 | if Process.alive?(channel.pid) do 388 | close_channel(config, channel) 389 | else 390 | :ok 391 | end 392 | end 393 | 394 | defp validate_merge_opts(opts) do 395 | case Keyword.fetch(opts, :merge_options) do 396 | {:ok, fun} when is_function(fun, 1) -> 397 | index = opts[:broadway][:index] || raise "missing broadway index" 398 | merge_opts = fun.(index) 399 | 400 | if Keyword.keyword?(merge_opts) do 401 | {:ok, Keyword.merge(opts, merge_opts)} 402 | else 403 | message = 404 | "The :merge_options function should return a keyword list, " <> 405 | "got: #{inspect(merge_opts)}" 406 | 407 | {:error, message} 408 | end 409 | 410 | {:ok, other} -> 411 | {:error, ":merge_options must be a function with arity 1, got: #{inspect(other)}"} 412 | 413 | :error -> 414 | {:ok, opts} 415 | end 416 | end 417 | 418 | def __validate_amqp_uri__(uri) when is_binary(uri) do 419 | case uri |> to_charlist() |> :amqp_uri.parse() do 420 | {:ok, _amqp_params} -> {:ok, uri} 421 | {:error, reason} -> {:error, "failed parsing AMQP URI: #{inspect(reason)}"} 422 | end 423 | end 424 | 425 | def __validate_amqp_uri__(_value), do: {:error, "failed parsing AMQP URI."} 426 | 427 | defp validate_declare_opts(declare_opts, queue) do 428 | if queue == "" and is_nil(declare_opts) do 429 | {:error, "can't use \"\" (server autogenerate) as the queue name without the :declare"} 430 | else 431 | :ok 432 | end 433 | end 434 | 435 | def __validate_binding__({exchange, binding_opts}) when is_binary(exchange) do 436 | case NimbleOptions.validate(binding_opts, @binding_opts_schema) do 437 | {:ok, validated_binding_opts} -> {:ok, {exchange, validated_binding_opts}} 438 | {:error, %NimbleOptions.ValidationError{} = reason} -> {:error, Exception.message(reason)} 439 | end 440 | end 441 | 442 | def __validate_binding__(other) do 443 | {:error, "expected binding to be a {exchange, opts} tuple, got: #{inspect(other)}"} 444 | end 445 | 446 | def __validate_custom_pool__({:custom_pool, module, _options} = value) when is_atom(module) do 447 | with {:module, ^module} <- Code.ensure_loaded(module), 448 | behaviours = 449 | module.__info__(:attributes) |> Keyword.get_values(:behaviour) |> List.flatten(), 450 | true <- Enum.any?(behaviours, &(&1 == BroadwayRabbitMQ.ChannelPool)) do 451 | {:ok, value} 452 | else 453 | _error -> 454 | {:error, 455 | "#{inspect(module)} must be a module that implements BroadwayRabbitMQ.ChannelPool behaviour"} 456 | end 457 | end 458 | 459 | def __validate_custom_pool__(_value), do: {:error, "invalid custom_pool option"} 460 | end 461 | -------------------------------------------------------------------------------- /lib/broadway_rabbitmq/backoff.ex: -------------------------------------------------------------------------------- 1 | defmodule BroadwayRabbitMQ.Backoff do 2 | @moduledoc false 3 | 4 | @min 1_000 5 | @max 30_000 6 | 7 | @type t() :: %__MODULE__{ 8 | type: :rand | :exp | :rand_exp, 9 | min: non_neg_integer(), 10 | max: non_neg_integer(), 11 | state: term() 12 | } 13 | 14 | defstruct [:type, :min, :max, :state] 15 | 16 | @spec new(keyword()) :: t() | nil 17 | def new(opts) when is_list(opts) do 18 | case Keyword.fetch!(opts, :backoff_type) do 19 | :stop -> 20 | nil 21 | 22 | type -> 23 | {min, max} = min_max(opts) 24 | new(type, min, max) 25 | end 26 | end 27 | 28 | @spec backoff(t()) :: {non_neg_integer(), t()} 29 | def backoff(backoff) 30 | 31 | def backoff(%__MODULE__{type: :rand, min: min, max: max, state: state} = s) do 32 | {backoff, state} = rand(state, min, max) 33 | {backoff, %__MODULE__{s | state: state}} 34 | end 35 | 36 | def backoff(%__MODULE__{type: :exp, min: min, state: nil} = s) do 37 | {min, %__MODULE__{s | state: min}} 38 | end 39 | 40 | def backoff(%__MODULE__{type: :exp, max: max, state: prev} = s) do 41 | require Bitwise 42 | next = min(Bitwise.<<<(prev, 1), max) 43 | {next, %__MODULE__{s | state: next}} 44 | end 45 | 46 | def backoff(%__MODULE__{type: :rand_exp, max: max, state: state} = s) do 47 | {prev, lower, rand_state} = state 48 | next_min = min(prev, lower) 49 | next_max = min(prev * 3, max) 50 | {next, rand_state} = rand(rand_state, next_min, next_max) 51 | {next, %__MODULE__{s | state: {next, lower, rand_state}}} 52 | end 53 | 54 | @spec reset(t()) :: t() 55 | def reset(backoff) 56 | 57 | def reset(%__MODULE__{type: :rand} = s), do: s 58 | def reset(%__MODULE__{type: :exp} = s), do: %__MODULE__{s | state: nil} 59 | 60 | def reset(%__MODULE__{type: :rand_exp, min: min, state: state} = s) do 61 | {_, lower, rand_state} = state 62 | %__MODULE__{s | state: {min, lower, rand_state}} 63 | end 64 | 65 | ## Internal 66 | 67 | defp min_max(opts) do 68 | case {opts[:backoff_min], opts[:backoff_max]} do 69 | {nil, nil} -> {@min, @max} 70 | {nil, max} -> {min(@min, max), max} 71 | {min, nil} -> {min, max(min, @max)} 72 | {min, max} -> {min, max} 73 | end 74 | end 75 | 76 | defp new(_, min, _) when not (is_integer(min) and min >= 0) do 77 | raise ArgumentError, "minimum #{inspect(min)} not 0 or a positive integer" 78 | end 79 | 80 | defp new(_, _, max) when not (is_integer(max) and max >= 0) do 81 | raise ArgumentError, "maximum #{inspect(max)} not 0 or a positive integer" 82 | end 83 | 84 | defp new(_, min, max) when min > max do 85 | raise ArgumentError, "minimum #{min} is greater than maximum #{max}" 86 | end 87 | 88 | defp new(:rand, min, max) do 89 | %__MODULE__{type: :rand, min: min, max: max, state: seed()} 90 | end 91 | 92 | defp new(:exp, min, max) do 93 | %__MODULE__{type: :exp, min: min, max: max, state: nil} 94 | end 95 | 96 | defp new(:rand_exp, min, max) do 97 | lower = max(min, div(max, 3)) 98 | %__MODULE__{type: :rand_exp, min: min, max: max, state: {min, lower, seed()}} 99 | end 100 | 101 | defp new(type, _, _) do 102 | raise ArgumentError, "unknown type #{inspect(type)}" 103 | end 104 | 105 | defp seed() do 106 | :rand.seed_s(:exsplus) 107 | end 108 | 109 | defp rand(state, min, max) do 110 | {int, state} = :rand.uniform_s(max - min + 1, state) 111 | 112 | {int + min - 1, state} 113 | end 114 | end 115 | -------------------------------------------------------------------------------- /lib/broadway_rabbitmq/channel_pool.ex: -------------------------------------------------------------------------------- 1 | defmodule BroadwayRabbitMQ.ChannelPool do 2 | @moduledoc """ 3 | A behaviour used to implement custom AMQP channel pools. 4 | 5 | By default, BroadwayRabbitMQ handles its own pool of AMQP connections and channels. However, 6 | there might be use cases where you want to use your own existing pool or custom pooling logic. 7 | In those cases, you can implement a custom channel pool using this behaviour. 8 | 9 | To use a custom pool, pass `{:custom_pool, module, args}` as the value of the `:connection` 10 | option in `BroadwayRabbitMQ.Producer`. `module` needs to implement this behaviour, and `args` is 11 | passed down to `c:checkout_channel/1` and `c:checkin_channel/2`. 12 | 13 | ## Examples 14 | 15 | Imagine we pass this option when starting the producer: 16 | 17 | connection: {:custom_pool, MyPool, _amqp_connection = :big_pool} 18 | 19 | Then, we could define the custom pool as: 20 | 21 | defmodule MyPool do 22 | @behaviour BroadwayRabbitMQ.ChannelPool 23 | 24 | @impl true 25 | def checkout_channel(name) do 26 | conn = %AMQP.Connection{pid: Process.whereis(name)} 27 | 28 | case AMQP.Channel.open(conn) do 29 | {:ok, channel} -> {:ok, channel} 30 | {:error, reason} -> {:error, %RuntimeError{message: inspect(reason)}} 31 | end 32 | end 33 | 34 | @impl true 35 | def checkin_channel(_name, channel) do 36 | case AMQP.Channel.close(channel) do 37 | :ok -> :ok 38 | {:error, reason} -> {:error, %RuntimeError{message: inspect(reason)}} 39 | end 40 | end 41 | end 42 | 43 | """ 44 | 45 | @moduledoc since: "0.8.0" 46 | 47 | @doc """ 48 | Invoked to check out a AMQP channel from the pool. 49 | 50 | If there is an error, you can return any exception as `{:error, exception}`. 51 | 52 | This callback is invoked from a `BroadwayRabbitMQ.Producer` process. If you need 53 | the PID of that process, call `self()` in your implementation. This can be useful 54 | for things like linking or monitoring. 55 | """ 56 | @callback checkout_channel(args :: term()) :: 57 | {:ok, AMQP.Channel.t()} | {:error, reason :: Exception.t()} 58 | 59 | @doc """ 60 | Invoked to check a channel back into the pool. 61 | 62 | `channel` is a channel that was returned by `c:checkout_channel/1`. 63 | 64 | If there is an error, you can return any exception as `{:error, exception}`. 65 | 66 | In case your pool fails to handle this properly, BroadwayRabbitMQ will try to close the channel 67 | by itself using `AMQP.Channel.close/1`. 68 | """ 69 | @callback checkin_channel(args :: term(), channel :: AMQP.Channel.t()) :: 70 | :ok | {:error, reason :: Exception.t()} 71 | end 72 | -------------------------------------------------------------------------------- /lib/broadway_rabbitmq/producer.ex: -------------------------------------------------------------------------------- 1 | defmodule BroadwayRabbitMQ.Producer do 2 | @valid_ack_values [:ack, :reject, :reject_and_requeue, :reject_and_requeue_once] 3 | 4 | @opts_schema [ 5 | # Internal. 6 | client: [type: :atom, doc: false], 7 | # Handled by Broadway. 8 | broadway: [type: :any, doc: false], 9 | buffer_size: [ 10 | type: :non_neg_integer, 11 | doc: """ 12 | Optional, but required if `:prefetch_count` under `:qos` is 13 | set to `0`. Defines the size of the buffer to store events without demand. 14 | Can be `:infinity` to signal no limit on the buffer size. This is used to 15 | configure the GenStage producer, see the `GenStage` docs for more details. 16 | Defaults to `:prefetch_count * 5`. 17 | """ 18 | ], 19 | buffer_keep: [ 20 | type: {:in, [:first, :last]}, 21 | doc: """ 22 | Optional. Used in the GenStage producer configuration. 23 | Defines whether the `:first` or `:last` entries should be kept on the 24 | buffer in case the buffer size is exceeded. Defaults to `:last`. 25 | """ 26 | ], 27 | on_success: [ 28 | type: {:in, @valid_ack_values}, 29 | doc: """ 30 | Configures the acking behaviour for successful messages. 31 | See the "Acking" section below for all the possible values. 32 | This option can also be changed for each message through 33 | `Broadway.Message.configure_ack/2`. 34 | """, 35 | default: :ack 36 | ], 37 | on_failure: [ 38 | type: {:in, @valid_ack_values}, 39 | doc: """ 40 | Configures the acking behaviour for failed messages. 41 | See the "Acking" section below for all the possible values. 42 | This option can also be changed for each message through 43 | `Broadway.Message.configure_ack/2`. 44 | """, 45 | default: :reject_and_requeue 46 | ], 47 | backoff_min: [ 48 | type: :non_neg_integer, 49 | doc: """ 50 | The minimum backoff interval (default: `1_000`). 51 | """ 52 | ], 53 | backoff_max: [ 54 | type: :non_neg_integer, 55 | doc: """ 56 | The maximum backoff interval (default: `30_000`). 57 | """ 58 | ], 59 | backoff_type: [ 60 | type: {:in, [:rand_exp, :exp, :rand, :stop]}, 61 | default: :rand_exp, 62 | doc: """ 63 | The backoff strategy: `:stop` for no backoff and 64 | to stop, `:exp` for exponential, `:rand` for random, and `:rand_exp` for 65 | random exponential (default: `:rand_exp`). 66 | """ 67 | ] 68 | ] 69 | 70 | @moduledoc """ 71 | A RabbitMQ producer for Broadway. 72 | 73 | ## Features 74 | 75 | * Automatically acknowledges/rejects messages. 76 | * Handles connection outages using backoff for retries. 77 | 78 | For a quick getting started on using Broadway with RabbitMQ, please see 79 | the [RabbitMQ Guide](https://hexdocs.pm/broadway/rabbitmq.html). 80 | 81 | ## Options 82 | 83 | #{NimbleOptions.docs(@opts_schema)} 84 | 85 | The following options apply to the underlying AMQP connection: 86 | 87 | #{NimbleOptions.docs(BroadwayRabbitMQ.AmqpClient.__opts_schema__())} 88 | 89 | Note AMQP provides the possibility to define the AMQP connection globally. 90 | This is not supported by Broadway. You must configure the connection 91 | directly in the Broadway pipeline, as shown in the next section. 92 | 93 | ## Example 94 | 95 | @processor_concurrency 50 96 | @max_demand 2 97 | 98 | Broadway.start_link(MyBroadway, 99 | name: MyBroadway, 100 | producer: [ 101 | module: 102 | {BroadwayRabbitMQ.Producer, 103 | queue: "my_queue", 104 | connection: [ 105 | username: "user", 106 | password: "password", 107 | host: "192.168.0.10" 108 | ], 109 | qos: [ 110 | # See "Back-pressure and `:prefetch_count`" section 111 | prefetch_count: @processor_concurrency * @max_demand 112 | ], 113 | on_failure: :reject_and_requeue}, 114 | # See "Producer concurrency" section 115 | concurrency: 1 116 | ], 117 | processors: [ 118 | default: [ 119 | concurrency: @processor_concurrency, 120 | # See "Max demand" section 121 | max_demand: @max_demand 122 | ] 123 | ] 124 | ) 125 | 126 | ## Producer concurrency 127 | 128 | For efficiency, you should generally limit the amount of internal queueing. 129 | Whenever additional messages are sitting in a busy processor's mailbox, they 130 | can't be delivered to another processor which may be available or become 131 | available first. 132 | 133 | One possible cause of internal queueing is multiple producers. This is because 134 | each processor's demand will be sent to all producers. For example, if a 135 | processor demands `2` messages and there are `2` producers, each producer 136 | will try to pull `2` messages and give them to the processor. So the 137 | processor may receive `max_demand * ` messages. 138 | 139 | Setting producer `concurrency: 1` will reduce internal queueing, so this is 140 | the recommended setting to start with. **Only increase producer concurrency 141 | if you can measure performance improvements in your system**. Adding another 142 | single-producer pipeline, or another node running the pipeline, are other 143 | ways you may consider to increase throughput. 144 | 145 | ## Back-pressure and `:prefetch_count` 146 | 147 | Unlike the BroadwaySQS producer, which polls for new messages, 148 | BroadwayRabbitMQ receives messages as they are are pushed by RabbitMQ. The 149 | `:prefetch_count` setting instructs RabbitMQ to [limit the number of 150 | unacknowledged messages a consumer will have at a given 151 | moment](https://www.rabbitmq.com/consumer-prefetch.html) (except with a value 152 | of `0`, which RabbitMQ treats as infinity). 153 | 154 | Setting a prefetch limit creates back-pressure from Broadway to RabbitMQ so 155 | that the pipeline is not overwhelmed with messages. But setting the limit too 156 | low will limit throughput. For example, if the `:prefetch_count` were `1`, 157 | only one message could be processed at a time, regardless of other settings. 158 | 159 | Although the RabbitMQ client has a default `:prefetch_count` of `0`, 160 | BroadwayRabbitMQ overwrites the default value to `50`, enabling the 161 | back-pressure mechanism. **To ensure that all processors in a given pipeline 162 | can receive messages, the value should be set to at least `max_demand * 163 | `**, as in the example above. 164 | 165 | Increasing it beyond that could be helpful if latency from RabbitMQ were 166 | high, and in the long term would not cause the pipeline to receive an unfair 167 | share of messages, since RabbitMQ uses round-robin delivery to all 168 | subscribers. It could mean that a newly-added subscriber would initially 169 | receives no messages, as they would have all been prefetched by the existing 170 | producer. 171 | 172 | If you're using batchers, you'll need a larger `:prefetch_count` to allow all 173 | batchers and processors to be busy simultaneously. Measure your system to 174 | decide what number works best. 175 | 176 | You can define `:prefetch_count` as `0` if you wish to disable back-pressure. 177 | However, if you do this, make sure the machine has enough resources to handle 178 | the number of messages coming from the broker, and set `:buffer_size` to an 179 | appropriate value. 180 | 181 | ## Max demand 182 | 183 | The best value for `max_demand` depends on how long your messages take to 184 | process. If processing time is long, consider setting it to `1`. Otherwise, 185 | the default value of `10` is a good starting point. 186 | 187 | Measure throughput in your own system to see how this setting affects it. 188 | 189 | ## Connection loss and backoff 190 | 191 | In case the connection cannot be opened or if an established connection is lost, 192 | the producer will try to reconnect using an exponential random backoff strategy. 193 | The strategy can be configured using the `:backoff_type` option. 194 | 195 | ## Declaring queues and binding them to exchanges 196 | 197 | In RabbitMQ, it's common for consumers to declare the queue they're going 198 | to consume from and bind it to the appropriate exchange when they start up. 199 | You can do these steps (either or both) when setting up your Broadway pipeline 200 | through the `:declare` and `:bindings` options. 201 | 202 | Broadway.start_link(MyBroadway, 203 | name: MyBroadway, 204 | producer: [ 205 | module: 206 | {BroadwayRabbitMQ.Producer, 207 | queue: "my_queue", 208 | declare: [], 209 | bindings: [{"my-exchange", []}]}, 210 | concurrency: 1 211 | ], 212 | processors: [ 213 | default: [] 214 | ] 215 | ) 216 | 217 | ## Acking 218 | 219 | You can use the `:on_success` and `:on_failure` options to control how messages 220 | are acked on RabbitMQ. By default, successful messages are acked and failed 221 | messages are rejected. You can set `:on_success` and `:on_failure` when starting 222 | the RabbitMQ producer, or change them for each message through 223 | `Broadway.Message.configure_ack/2`. You can also ack a message *before* the end of the Broadway 224 | pipeline by using `Broadway.Message.ack_immediately/1`, which determines whether to ack or 225 | reject based on `:on_success`/`:on_failure` too. 226 | 227 | Here is the list of all possible values supported by `:on_success` and `:on_failure`: 228 | 229 | * `:ack` - acknowledge the message. RabbitMQ will mark the message as acked and 230 | will not redeliver it to any other consumer. This is done via `AMQP.Basic.ack/3`. 231 | 232 | * `:reject` - rejects the message without requeuing (basically, discards 233 | the message). RabbitMQ will not redeliver the message to any other 234 | consumer, but a queue can be configured to send rejected messages to a 235 | [dead letter exchange](https://www.rabbitmq.com/dlx.html), where another 236 | consumer can see why it was dead lettered, how many times, and so on, and 237 | potentially republish it. Rejecting is done through `AMQP.Basic.reject/3` 238 | with the `:requeue` option set to `false`. 239 | 240 | * `:reject_and_requeue` - rejects the message and tells RabbitMQ to requeue it so 241 | that it can be delivered to a consumer again. `:reject_and_requeue` 242 | always requeues the message. If the message is unprocessable, this will 243 | cause an infinite loop of retries. Rejecting is done through `AMQP.Basic.reject/3` 244 | with the `:requeue` option set to `true`. 245 | 246 | * `:reject_and_requeue_once` - rejects the message and tells RabbitMQ to requeue it 247 | the first time. If a message was already requeued and redelivered, it will be 248 | rejected and not requeued again. This feature uses Broadway-specific message metadata, 249 | not RabbitMQ's dead lettering feature. Rejecting is done through `AMQP.Basic.reject/3`. 250 | 251 | If you pass the `no_ack: true` option under `:consume_options`, then RabbitMQ will consider 252 | every message delivered to a consumer as **acked**, so the settings above have no effect. 253 | In those cases, calling `Broadway.Message.ack_immediately/1` also has no effect. 254 | 255 | ### Choosing the right requeue strategy 256 | 257 | Choose the requeue strategy carefully. 258 | 259 | If you set the value to `:reject` or `:reject_and_requeue_once`, make sure you handle failed 260 | messages properly, either by logging them somewhere or redirecting them to a dead-letter queue 261 | for future inspection. These strategies are useful when you want to implement **at most once** 262 | processing: you want your messages to be processed at most once, but if they fail, you prefer 263 | that they're not re-processed. It's common to pair this requeue strategy with the use of 264 | `Broadway.Message.ack_immediately/1` in order to ack the message before doing any work, 265 | so that if the consumer loses connection to RabbitMQ while processing, the message will have 266 | been acked and RabbitMQ will not deliver it to another consumer. For example: 267 | 268 | def handle_message(_, message, _context) do 269 | Broadway.Message.ack_immediately(message) 270 | process_message(message) 271 | message 272 | end 273 | 274 | `:reject_and_requeue` is commonly used when you are implementing **at least once** processing 275 | semantics. You want messages to be processed at least once, so if something goes wrong and they 276 | get rejected, they'll be requeued and redelivered to a consumer. 277 | When using `:reject_and_requeue`, pay attention that requeued messages by default will 278 | be instantly redelivered, which may result in very high unnecessary workload. 279 | One way to handle this is by using [Dead Letter Exchanges](https://www.rabbitmq.com/dlx.html) 280 | and [TTL and Expiration](https://www.rabbitmq.com/ttl.html). 281 | 282 | ## Metadata 283 | 284 | You can retrieve additional information about your message by setting the `:metadata` option 285 | when starting the producer. This is useful in a handful of situations like when you are 286 | interested in the message headers or in knowing if the message is new or redelivered. 287 | Metadata is added to the `metadata` field in the `Broadway.Message` struct. 288 | 289 | These are the keys in the metadata map that are *always present*: 290 | 291 | * `:amqp_channel` - It contains the `AMQP.Channel` struct. You can use it to do things 292 | like publish messages back to RabbitMQ (for use cases such as RPCs). You *should not* 293 | do things with the channel other than publish messages with `AMQP.Basic.publish/5`. Other 294 | operations may result in undesired effects. 295 | 296 | Here is the list of all possible values supported by `:metadata`: 297 | 298 | * `:delivery_tag` - an integer that uniquely identifies the delivery on a channel. 299 | It's used internally in AMQP client library methods, like acknowledging or rejecting a message. 300 | 301 | * `:redelivered` - a boolean representing if the message was already rejected and requeued before. 302 | 303 | * `:exchange` - the name of the exchange the queue was bound to. 304 | 305 | * `:routing_key` - the name of the queue from which the message was consumed. 306 | 307 | * `:content_type` - the MIME type of the message. 308 | 309 | * `:content_encoding` - the MIME content encoding of the message. 310 | 311 | * `:headers` - the headers of the message, which are returned in tuples of type 312 | `{String.t(), argument_type(), term()}`. The last value of the tuple is the value of 313 | the header. You can find a list of argument types 314 | [here](https://hexdocs.pm/amqp/readme.html#types-of-arguments-and-headers). 315 | 316 | * `:persistent` - a boolean stating whether or not the message was published with disk persistence. 317 | 318 | * `:priority` - an integer representing the message priority on the queue. 319 | 320 | * `:correlation_id` - it's a useful property of AMQP protocol to correlate RPC requests. 321 | You can read more about RPC in RabbitMQ 322 | [here](https://www.rabbitmq.com/tutorials/tutorial-six-python.html). 323 | 324 | * `:message_id` - application specific message identifier. 325 | 326 | * `:timestamp` - a timestamp associated with the message. 327 | 328 | * `:type` - message type as a string. 329 | 330 | * `:user_id` - a user identifier that could have been assigned during message publication. 331 | RabbitMQ validated this value against the active connection when the message was published. 332 | 333 | * `:app_id` - publishing application identifier. 334 | 335 | * `:cluster_id` - RabbitMQ cluster identifier. 336 | 337 | * `:reply_to` - name of the reply queue. 338 | 339 | ## Telemetry 340 | 341 | This producer emits a few [Telemetry](https://github.com/beam-telemetry/telemetry) 342 | events which are listed below. 343 | 344 | * `[:broadway_rabbitmq, :amqp, :open_connection, :start | :stop | :exception]` spans - 345 | these events are emitted in "span style" when opening an AMQP connection. 346 | See `:telemetry.span/3`. 347 | 348 | All these events have the measurements described in `:telemetry.span/3`. The events 349 | contain the following metadata: 350 | 351 | * `:connection_name` - the name of the AMQP connection (or `nil` if it doesn't have a name) 352 | * `:connection` - the connection info passed when starting the producer (either a URI 353 | or a keyword list of options) 354 | 355 | * `[:broadway_rabbitmq, :amqp, :ack, :start | :stop | :exception]` span - these events 356 | are emitted in "span style" when acking messages on RabbitMQ. See `:telemetry.span/3`. 357 | 358 | All these events have the measurements described in `:telemetry.span/3`. The events 359 | contain no metadata. 360 | 361 | * `[:broadway_rabbitmq, :amqp, :reject, :start | :stop | :exception]` span - these events 362 | are emitted in "span style" when rejecting messages on RabbitMQ. See `:telemetry.span/3`. 363 | 364 | All these events have the measurements described in `:telemetry.span/3`. The `[..., :start]` 365 | event contains the following metadata: 366 | 367 | * `:requeue` - a boolean telling if this "reject" is asking RabbitMQ to requeue the message 368 | or not. 369 | 370 | * `[:broadway_rabbitmq, :amqp, :connection_failure]` execution - this event is executed when 371 | the connection to RabbitMQ fails. See `:telemetry.execute/3`. 372 | 373 | The event contains the following metadata: 374 | 375 | * `:reason` - the reason for the failure. 376 | 377 | ## Dead-letter Exchanges 378 | 379 | Here's an example of how to use a dead-letter exchange setup with broadway_rabbitmq: 380 | 381 | defmodule MyPipeline do 382 | use Broadway 383 | 384 | @queue "my_queue" 385 | @exchange "my_exchange" 386 | @queue_dlx "my_queue.dlx" 387 | @exchange_dlx "my_exchange.dlx" 388 | 389 | def start_link(_opts) do 390 | Broadway.start_link(__MODULE__, 391 | name: __MODULE__, 392 | producer: [ 393 | module: { 394 | BroadwayRabbitMQ.Producer, 395 | on_failure: :reject, 396 | after_connect: &declare_rabbitmq_topology/1, 397 | queue: @queue, 398 | declare: [ 399 | durable: true, 400 | arguments: [ 401 | {"x-dead-letter-exchange", :longstr, @exchange_dlx}, 402 | {"x-dead-letter-routing-key", :longstr, @queue_dlx} 403 | ] 404 | ], 405 | bindings: [{@exchange, []}], 406 | }, 407 | concurrency: 2 408 | ], 409 | processors: [default: [concurrency: 4]] 410 | ) 411 | end 412 | 413 | defp declare_rabbitmq_topology(amqp_channel) do 414 | with :ok <- AMQP.Exchange.declare(amqp_channel, @exchange, :fanout, durable: true), 415 | :ok <- AMQP.Exchange.declare(amqp_channel, @exchange_dlx, :fanout, durable: true), 416 | {:ok, _} <- AMQP.Queue.declare(amqp_channel, @queue_dlx, durable: true), 417 | :ok <- AMQP.Queue.bind(amqp_channel, @queue_dlx, @exchange_dlx) do 418 | :ok 419 | end 420 | end 421 | 422 | @impl true 423 | def handle_message(_processor, message, _context) do 424 | # Raising errors or returning a "failed" message here sends the message to the 425 | # dead-letter queue. 426 | end 427 | end 428 | 429 | """ 430 | 431 | use GenStage 432 | 433 | require Logger 434 | 435 | alias Broadway.{Message, Acknowledger, Producer} 436 | alias BroadwayRabbitMQ.Backoff 437 | 438 | @behaviour Acknowledger 439 | @behaviour Producer 440 | 441 | @impl true 442 | def init(opts) do 443 | Process.flag(:trap_exit, true) 444 | 445 | maybe_warn_unspecified_on_failure_opt(opts) 446 | {opts, client_opts} = Keyword.split(opts, Keyword.keys(@opts_schema) -- [:broadway]) 447 | 448 | opts = 449 | case NimbleOptions.validate(opts, @opts_schema) do 450 | {:ok, opts} -> opts 451 | {:error, reason} -> raise ArgumentError, Exception.message(reason) 452 | end 453 | 454 | client = Keyword.get(opts, :client, BroadwayRabbitMQ.AmqpClient) 455 | gen_stage_opts = Keyword.take(opts, [:buffer_size, :buffer_keep]) 456 | on_success = Keyword.fetch!(opts, :on_success) 457 | on_failure = Keyword.fetch!(opts, :on_failure) 458 | backoff_opts = Keyword.take(opts, [:backoff_min, :backoff_max, :backoff_type]) 459 | 460 | config = init_client!(client, client_opts) 461 | 462 | send(self(), {:connect, :no_init_client}) 463 | 464 | prefetch_count = config[:qos][:prefetch_count] 465 | options = producer_options(gen_stage_opts, prefetch_count) 466 | 467 | {:producer, 468 | %{ 469 | client: client, 470 | channel: nil, 471 | consumer_tag: nil, 472 | config: config, 473 | backoff: Backoff.new(backoff_opts), 474 | channel_ref: nil, 475 | opts: client_opts, 476 | on_success: on_success, 477 | on_failure: on_failure 478 | }, options} 479 | end 480 | 481 | @impl true 482 | def handle_demand(_incoming_demand, state) do 483 | {:noreply, [], state} 484 | end 485 | 486 | @impl true 487 | def handle_info({:basic_consume_ok, %{consumer_tag: tag}}, %{consumer_tag: tag} = state) do 488 | {:noreply, [], %{state | consumer_tag: tag}} 489 | end 490 | 491 | # RabbitMQ sends this in a few scenarios, like if the queue this consumer 492 | # is consuming from gets deleted. See https://www.rabbitmq.com/consumer-cancel.html. 493 | def handle_info({:basic_cancel, %{consumer_tag: tag}}, %{consumer_tag: tag} = state) do 494 | log_warn("Received AMQP basic_cancel from RabbitMQ") 495 | state = disconnect(state) 496 | {:noreply, [], connect(state, :init_client)} 497 | end 498 | 499 | def handle_info({:basic_cancel_ok, %{consumer_tag: tag}}, %{consumer_tag: tag} = state) do 500 | {:noreply, [], %{state | consumer_tag: nil}} 501 | end 502 | 503 | def handle_info({:basic_deliver, payload, meta}, state) do 504 | %{channel: channel, client: client, config: config} = state 505 | %{delivery_tag: tag, redelivered: redelivered} = meta 506 | 507 | acknowledger = 508 | if config[:consume_options][:no_ack] do 509 | {Broadway.NoopAcknowledger, _ack_ref = nil, _data = nil} 510 | else 511 | ack_data = %{ 512 | delivery_tag: tag, 513 | client: client, 514 | redelivered: redelivered, 515 | on_success: state.on_success, 516 | on_failure: state.on_failure 517 | } 518 | 519 | {__MODULE__, _ack_ref = channel, ack_data} 520 | end 521 | 522 | metadata = 523 | meta 524 | |> Map.take(config[:metadata]) 525 | |> Map.put(:amqp_channel, channel) 526 | 527 | message = %Message{ 528 | data: payload, 529 | metadata: metadata, 530 | acknowledger: acknowledger 531 | } 532 | 533 | {:noreply, [message], state} 534 | end 535 | 536 | def handle_info({:EXIT, conn_pid, reason}, %{channel: %{conn: %{pid: conn_pid}}} = state) do 537 | log_warn("AMQP connection went down with reason: #{inspect(reason)}") 538 | state = %{state | channel: nil, consumer_tag: nil} 539 | {:noreply, [], connect(state, :init_client)} 540 | end 541 | 542 | def handle_info({:DOWN, ref, :process, _pid, reason}, %{channel_ref: ref} = state) do 543 | log_warn("AMQP channel went down with reason: #{inspect(reason)}") 544 | state = disconnect(state) 545 | {:noreply, [], connect(state, :init_client)} 546 | end 547 | 548 | def handle_info({:connect, mode}, state) when mode in [:init_client, :no_init_client] do 549 | {:noreply, [], connect(state, mode)} 550 | end 551 | 552 | def handle_info(_, state) do 553 | {:noreply, [], state} 554 | end 555 | 556 | @impl true 557 | def terminate(_reason, state) do 558 | _state = disconnect(state) 559 | :ok 560 | end 561 | 562 | @impl Acknowledger 563 | def ack(_ack_ref = channel, successful, failed) do 564 | ack_messages(successful, channel, :successful) 565 | ack_messages(failed, channel, :failed) 566 | end 567 | 568 | @impl Acknowledger 569 | def configure(_channel, ack_data, options) do 570 | Enum.each(options, fn 571 | {name, val} when name in [:on_success, :on_failure] -> assert_valid_ack_option!(name, val) 572 | {other, _value} -> raise ArgumentError, "unsupported configure option #{inspect(other)}" 573 | end) 574 | 575 | ack_data = Map.merge(ack_data, Map.new(options)) 576 | {:ok, ack_data} 577 | end 578 | 579 | defp assert_valid_ack_option!(name, value) do 580 | unless value in @valid_ack_values do 581 | raise ArgumentError, "unsupported value for #{inspect(name)} option: #{inspect(value)}" 582 | end 583 | end 584 | 585 | @impl Producer 586 | def prepare_for_draining(%{channel: nil} = state) do 587 | {:noreply, [], state} 588 | end 589 | 590 | def prepare_for_draining(state) do 591 | %{client: client, channel: channel, consumer_tag: consumer_tag} = state 592 | 593 | case client.cancel(channel, consumer_tag) do 594 | {:ok, ^consumer_tag} -> 595 | {:noreply, [], state} 596 | 597 | {:error, error} -> 598 | Logger.error("Could not cancel producer while draining. Channel is #{error}") 599 | {:noreply, [], state} 600 | end 601 | end 602 | 603 | defp producer_options(opts, 0) do 604 | if opts[:buffer_size] do 605 | opts 606 | else 607 | raise ArgumentError, ":prefetch_count is 0, specify :buffer_size explicitly" 608 | end 609 | end 610 | 611 | defp producer_options(opts, prefetch_count) do 612 | Keyword.put_new(opts, :buffer_size, prefetch_count * 5) 613 | end 614 | 615 | defp ack_messages(messages, channel, kind) do 616 | errors = 617 | Enum.flat_map(messages, fn %{acknowledger: {_module, _channel, ack_data}} = msg -> 618 | case apply_ack_func(kind, ack_data, channel) do 619 | :ok -> 620 | [] 621 | 622 | {:error, reason} -> 623 | Logger.error(""" 624 | Could not ack or reject message. 625 | 626 | Message: #{inspect(msg)} 627 | Reason: #{inspect(reason)} 628 | """) 629 | 630 | [{msg, reason}] 631 | end 632 | end) 633 | 634 | case errors do 635 | [] -> 636 | :ok 637 | 638 | [{msg, reason} | _other_errors] -> 639 | raise RuntimeError, """ 640 | Could not ack or reject one or more messages. An example failure is provided. There may \ 641 | be more in logging. 642 | 643 | Message: #{inspect(msg)} 644 | Reason: #{inspect(reason)} 645 | """ 646 | end 647 | end 648 | 649 | defp apply_ack_func(:successful, ack_data, channel) do 650 | apply_ack_func(ack_data.on_success, ack_data, channel) 651 | end 652 | 653 | defp apply_ack_func(:failed, ack_data, channel) do 654 | apply_ack_func(ack_data.on_failure, ack_data, channel) 655 | end 656 | 657 | defp apply_ack_func(:ack, ack_data, channel) do 658 | ack_data.client.ack(channel, ack_data.delivery_tag) 659 | end 660 | 661 | defp apply_ack_func(reject, ack_data, channel) 662 | when reject in [:reject, :reject_and_requeue, :reject_and_requeue_once] do 663 | options = [requeue: requeue?(reject, ack_data.redelivered)] 664 | ack_data.client.reject(channel, ack_data.delivery_tag, options) 665 | end 666 | 667 | defp requeue?(:reject, _redelivered), do: false 668 | defp requeue?(:reject_and_requeue, _redelivered), do: true 669 | defp requeue?(:reject_and_requeue_once, redelivered), do: !redelivered 670 | 671 | defp disconnect(%{channel: channel, client: client, config: config} = state) do 672 | if channel do 673 | _ = client.close_connection(config, channel) 674 | %{state | channel: nil} 675 | else 676 | state 677 | end 678 | end 679 | 680 | defp connect(state, mode) when mode in [:init_client, :no_init_client] do 681 | %{client: client, config: config, backoff: backoff, opts: opts} = state 682 | 683 | config = 684 | if mode == :no_init_client do 685 | config 686 | else 687 | init_client!(client, opts) 688 | end 689 | 690 | case client.setup_channel(config) do 691 | {:ok, channel} -> 692 | # We monitor the channel but link to the connection (in the client, not here). 693 | channel_ref = Process.monitor(channel.pid) 694 | backoff = backoff && Backoff.reset(backoff) 695 | consumer_tag = client.consume(channel, config) 696 | 697 | %{ 698 | state 699 | | channel: channel, 700 | config: config, 701 | consumer_tag: consumer_tag, 702 | backoff: backoff, 703 | channel_ref: channel_ref 704 | } 705 | 706 | {:error, reason} -> 707 | handle_connection_failure(state, reason) 708 | end 709 | end 710 | 711 | defp handle_connection_failure(state, reason) do 712 | _ = Logger.error("Cannot connect to RabbitMQ broker: #{inspect(reason)}") 713 | 714 | :telemetry.execute( 715 | [:broadway_rabbitmq, :amqp, :connection_failure], 716 | %{system_time: System.system_time()}, 717 | %{reason: reason} 718 | ) 719 | 720 | case reason do 721 | {:auth_failure, ~c"Disconnected"} -> 722 | handle_backoff(state) 723 | 724 | {:socket_closed_unexpectedly, :"connection.start"} -> 725 | handle_backoff(state) 726 | 727 | reason when reason in [:econnrefused, :unknown_host, :not_allowed] -> 728 | handle_backoff(state) 729 | 730 | _other -> 731 | _ = Logger.error("Crashing because of unexpected error when connecting to RabbitMQ") 732 | raise "unexpected error when connecting to RabbitMQ broker" 733 | end 734 | end 735 | 736 | defp handle_backoff(%{backoff: backoff} = state) do 737 | new_backoff = 738 | if backoff do 739 | {timeout, backoff} = Backoff.backoff(backoff) 740 | Process.send_after(self(), {:connect, :init_client}, timeout) 741 | backoff 742 | end 743 | 744 | %{ 745 | state 746 | | channel: nil, 747 | consumer_tag: nil, 748 | backoff: new_backoff, 749 | channel_ref: nil 750 | } 751 | end 752 | 753 | defp init_client!(client, opts) do 754 | case client.init(opts) do 755 | {:ok, config} -> 756 | config 757 | 758 | {:error, message} -> 759 | raise ArgumentError, "invalid options given to #{inspect(client)}.init/1, " <> message 760 | end 761 | end 762 | 763 | # TODO: Remove when we remove the default value 764 | defp maybe_warn_unspecified_on_failure_opt(opts) do 765 | unless Keyword.has_key?(opts, :on_failure) do 766 | name = get_in(opts, [:broadway, :name]) 767 | 768 | {:ok, vsn} = :application.get_key(:broadway_rabbitmq, :vsn) 769 | 770 | IO.warn( 771 | ":on_failure should be specified for Broadway topology with name #{inspect(name)}; " <> 772 | "assuming :reject_and_requeue. See documentation for valid values: " <> 773 | "https://hexdocs.pm/broadway_rabbitmq/#{vsn}/BroadwayRabbitMQ.Producer.html#module-acking" 774 | ) 775 | end 776 | end 777 | 778 | # TODO: remove this conditional when we depend on Elixir 1.11+. 779 | if macro_exported?(Logger, :warning, 1) do 780 | defp log_warn(message), do: Logger.warning(message) 781 | else 782 | defp log_warn(message), do: Logger.warn(message) 783 | end 784 | end 785 | -------------------------------------------------------------------------------- /lib/broadway_rabbitmq/rabbitmq_client.ex: -------------------------------------------------------------------------------- 1 | defmodule BroadwayRabbitMQ.RabbitmqClient do 2 | @moduledoc false 3 | 4 | alias AMQP.{Basic, Channel} 5 | 6 | @typep config :: %{ 7 | connection: keyword, 8 | name: binary() | :undefined, 9 | qos: keyword, 10 | metadata: list(atom()), 11 | queue: String.t() 12 | } 13 | 14 | @callback init(opts :: any) :: {:ok, config} | {:error, any} 15 | @callback setup_channel(config) :: {:ok, Channel.t()} | {:error, any} 16 | @callback ack(channel :: Channel.t(), delivery_tag :: Basic.delivery_tag()) :: any 17 | @callback reject(channel :: Channel.t(), delivery_tag :: Basic.delivery_tag(), opts :: keyword) :: 18 | any 19 | @callback consume(channel :: Channel.t(), config) :: Basic.consumer_tag() 20 | @callback cancel(channel :: Channel.t(), Basic.consumer_tag()) :: :ok | Basic.error() 21 | @callback close_connection(config, channel :: Channel.t()) :: :ok | {:error, any} 22 | end 23 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule BroadwayRabbitMQ.MixProject do 2 | use Mix.Project 3 | 4 | @version "0.8.2" 5 | @description "A RabbitMQ connector for Broadway" 6 | @source_url "https://github.com/dashbitco/broadway_rabbitmq" 7 | 8 | def project do 9 | [ 10 | app: :broadway_rabbitmq, 11 | version: @version, 12 | elixir: "~> 1.13", 13 | name: "BroadwayRabbitMQ", 14 | description: @description, 15 | start_permanent: Mix.env() == :prod, 16 | deps: deps(), 17 | docs: docs(), 18 | package: package(), 19 | test_coverage: [tool: ExCoveralls], 20 | preferred_cli_env: [ 21 | docs: :docs, 22 | "coveralls.html": :test 23 | ] 24 | ] 25 | end 26 | 27 | def application do 28 | [ 29 | extra_applications: [:logger] 30 | ] 31 | end 32 | 33 | defp deps do 34 | [ 35 | {:broadway, "~> 1.0"}, 36 | {:amqp, "~> 1.3 or ~> 2.0 or ~> 3.0 or ~> 4.0"}, 37 | {:nimble_options, "~> 0.3.5 or ~> 0.4.0 or ~> 1.0"}, 38 | {:telemetry, "~> 0.4.3 or ~> 1.0"}, 39 | 40 | # Dev and test dependencies 41 | {:ex_doc, ">= 0.25.0", only: :docs}, 42 | {:excoveralls, "~> 0.18.0", only: :test} 43 | ] 44 | end 45 | 46 | defp docs do 47 | [ 48 | main: "BroadwayRabbitMQ.Producer", 49 | source_ref: "v#{@version}", 50 | source_url: @source_url, 51 | extras: ["CHANGELOG.md"] 52 | ] 53 | end 54 | 55 | defp package do 56 | %{ 57 | licenses: ["Apache-2.0"], 58 | links: %{ 59 | "Changelog" => @source_url <> "/blob/master/CHANGELOG.md", 60 | "GitHub" => @source_url 61 | } 62 | } 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "amqp": {:hex, :amqp, "4.0.0", "c62c0eba8ad5f5bbebf668ca4a68bbf8d9e35c9b3bc8703ff679c01f3e6899d3", [:mix], [{:amqp_client, "~> 4.0", [hex: :amqp_client, repo: "hexpm", optional: false]}], "hexpm", "4148c54dc35733e6c2f9208ff26bc61601cde2da993f752a3452442b018d5735"}, 3 | "amqp_client": {:hex, :amqp_client, "4.0.3", "c7dcc8031c780cd39ec586ba827a8eb26e006e9761af8d3f58fded11f645ebd4", [:make, :rebar3], [{:credentials_obfuscation, "3.4.0", [hex: :credentials_obfuscation, repo: "hexpm", optional: false]}, {:rabbit_common, "4.0.3", [hex: :rabbit_common, repo: "hexpm", optional: false]}], "hexpm", "ae945f7280617e9a4b17a6d49e3a2f496d716e8088ec29d8e94ecc79e5da7458"}, 4 | "broadway": {:hex, :broadway, "1.1.0", "8ed3aea01fd6f5640b3e1515b90eca51c4fc1fac15fb954cdcf75dc054ae719c", [:mix], [{:gen_stage, "~> 1.0", [hex: :gen_stage, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.3.7 or ~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "25e315ef1afe823129485d981dcc6d9b221cea30e625fd5439e9b05f44fb60e4"}, 5 | "credentials_obfuscation": {:hex, :credentials_obfuscation, "3.4.0", "34e18b126b3aefd6e8143776fbe1ceceea6792307c99ac5ee8687911f048cfd7", [:rebar3], [], "hexpm", "738ace0ed5545d2710d3f7383906fc6f6b582d019036e5269c4dbd85dbced566"}, 6 | "earmark_parser": {:hex, :earmark_parser, "1.4.41", "ab34711c9dc6212dda44fcd20ecb87ac3f3fce6f0ca2f28d4a00e4154f8cd599", [:mix], [], "hexpm", "a81a04c7e34b6617c2792e291b5a2e57ab316365c2644ddc553bb9ed863ebefa"}, 7 | "ex_doc": {:hex, :ex_doc, "0.34.2", "13eedf3844ccdce25cfd837b99bea9ad92c4e511233199440488d217c92571e8", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "5ce5f16b41208a50106afed3de6a2ed34f4acfd65715b82a0b84b49d995f95c1"}, 8 | "excoveralls": {:hex, :excoveralls, "0.18.3", "bca47a24d69a3179951f51f1db6d3ed63bca9017f476fe520eb78602d45f7756", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "746f404fcd09d5029f1b211739afb8fb8575d775b21f6a3908e7ce3e640724c6"}, 9 | "gen_stage": {:hex, :gen_stage, "1.2.1", "19d8b5e9a5996d813b8245338a28246307fd8b9c99d1237de199d21efc4c76a1", [:mix], [], "hexpm", "83e8be657fa05b992ffa6ac1e3af6d57aa50aace8f691fcf696ff02f8335b001"}, 10 | "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 11 | "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, 12 | "makeup_elixir": {:hex, :makeup_elixir, "1.0.0", "74bb8348c9b3a51d5c589bf5aebb0466a84b33274150e3b6ece1da45584afc82", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "49159b7d7d999e836bedaf09dcf35ca18b312230cf901b725a64f3f42e407983"}, 13 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.1", "c7f58c120b2b5aa5fd80d540a89fdf866ed42f1f3994e4fe189abebeab610839", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "8a89a1eeccc2d798d6ea15496a6e4870b75e014d1af514b1b71fa33134f57814"}, 14 | "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, 15 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, 16 | "rabbit_common": {:hex, :rabbit_common, "4.0.3", "e927b882733d122f6802662220bdb1a83774852dbe67d21d4e33aaf54f3998dd", [:make, :rebar3], [{:credentials_obfuscation, "3.4.0", [hex: :credentials_obfuscation, repo: "hexpm", optional: false]}, {:ranch, "2.1.0", [hex: :ranch, repo: "hexpm", optional: false]}, {:recon, "2.5.6", [hex: :recon, repo: "hexpm", optional: false]}, {:thoas, "1.2.1", [hex: :thoas, repo: "hexpm", optional: false]}], "hexpm", "ead31ba292c2cc5fda48a486417d7cfe8966f661994d7ed6c3e5f8840828e8ec"}, 17 | "ranch": {:hex, :ranch, "2.1.0", "2261f9ed9574dcfcc444106b9f6da155e6e540b2f82ba3d42b339b93673b72a3", [:make, :rebar3], [], "hexpm", "244ee3fa2a6175270d8e1fc59024fd9dbc76294a321057de8f803b1479e76916"}, 18 | "recon": {:hex, :recon, "2.5.6", "9052588e83bfedfd9b72e1034532aee2a5369d9d9343b61aeb7fbce761010741", [:mix, :rebar3], [], "hexpm", "96c6799792d735cc0f0fd0f86267e9d351e63339cbe03df9d162010cefc26bb0"}, 19 | "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, 20 | "thoas": {:hex, :thoas, "1.2.1", "19a25f31177a17e74004d4840f66d791d4298c5738790fa2cc73731eb911f195", [:rebar3], [], "hexpm", "e38697edffd6e91bd12cea41b155115282630075c2a727e7a6b2947f5408b86a"}, 21 | } 22 | -------------------------------------------------------------------------------- /test/broadway_rabbitmq/ampq_client_test.exs: -------------------------------------------------------------------------------- 1 | defmodule BroadwayRabbitMQ.AmqpClientTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias BroadwayRabbitMQ.AmqpClient 5 | 6 | test "default options" do 7 | assert {:ok, 8 | %{ 9 | connection: [], 10 | qos: [prefetch_count: 50], 11 | metadata: [], 12 | bindings: [], 13 | declare_opts: nil, 14 | queue: "queue", 15 | after_connect: after_connect 16 | }} = AmqpClient.init(queue: "queue") 17 | 18 | assert after_connect.(:channel) == :ok 19 | end 20 | 21 | test "connection name" do 22 | assert {:ok, %{name: "conn_name"}} = AmqpClient.init(queue: "queue", name: "conn_name") 23 | end 24 | 25 | describe "validate init options" do 26 | test "supported options" do 27 | after_connect = fn _ -> :ok end 28 | 29 | connection = [ 30 | username: nil, 31 | password: nil, 32 | virtual_host: nil, 33 | host: nil, 34 | port: nil, 35 | channel_max: nil, 36 | frame_max: nil, 37 | heartbeat: nil, 38 | connection_timeout: nil, 39 | ssl_options: nil, 40 | client_properties: nil, 41 | socket_options: nil 42 | ] 43 | 44 | qos = [ 45 | prefetch_size: 1, 46 | prefetch_count: 1 47 | ] 48 | 49 | options = [ 50 | queue: "queue", 51 | connection: connection, 52 | qos: qos, 53 | after_connect: after_connect, 54 | consume_options: [no_ack: true, exclusive: false] 55 | ] 56 | 57 | metadata = [] 58 | 59 | assert AmqpClient.init(options) == 60 | {:ok, 61 | %{ 62 | connection: connection, 63 | name: :undefined, 64 | qos: qos, 65 | metadata: metadata, 66 | bindings: [], 67 | declare_opts: nil, 68 | queue: "queue", 69 | after_connect: after_connect, 70 | consume_options: [no_ack: true, exclusive: false] 71 | }} 72 | end 73 | 74 | test "providing connection via a URI" do 75 | connection = "amqp://guest:guest@127.0.0.1" 76 | 77 | assert {:ok, %{connection: ^connection, queue: "queue"}} = 78 | AmqpClient.init(queue: "queue", connection: connection) 79 | end 80 | 81 | test "invalid URI" do 82 | assert {:error, message} = AmqpClient.init(queue: "queue", connection: "http://example.com") 83 | assert message =~ "failed parsing AMQP URI" 84 | end 85 | 86 | test "custom pool module which implements BroadwayRabbitMQ.ChannelPool behaviour" do 87 | defmodule ValidPool do 88 | @behaviour BroadwayRabbitMQ.ChannelPool 89 | 90 | @impl true 91 | def checkout_channel(_args), do: {:ok, %AMQP.Channel{}} 92 | 93 | @impl true 94 | def checkin_channel(_args, _channel), do: :ok 95 | end 96 | 97 | custom_pool = {:custom_pool, ValidPool, []} 98 | 99 | assert {:ok, %{connection: ^custom_pool}} = 100 | AmqpClient.init(queue: "queue", connection: custom_pool) 101 | after 102 | :code.delete(ValidPool) 103 | :code.purge(ValidPool) 104 | end 105 | 106 | test "custom pool module which doesn't implement BroadwayRabbitMQ.ChannelPool behaviour" do 107 | custom_pool = {:custom_pool, URI, []} 108 | assert {:error, message} = AmqpClient.init(queue: "queue", connection: custom_pool) 109 | assert message =~ "implements BroadwayRabbitMQ.ChannelPool" 110 | end 111 | 112 | test "unsupported options for Broadway" do 113 | assert {:error, message} = AmqpClient.init(queue: "queue", option_1: 1, option_2: 2) 114 | assert message =~ "unknown options [:option_1, :option_2], valid options are" 115 | end 116 | 117 | test "unsupported options for :connection" do 118 | assert {:error, message} = 119 | AmqpClient.init(queue: "queue", connection: [option_1: 1, option_2: 2]) 120 | 121 | assert message =~ "unknown options [:option_1, :option_2], valid options are" 122 | assert message =~ "in options [:connection]" 123 | end 124 | 125 | test "unsupported options for :qos" do 126 | assert {:error, message} = AmqpClient.init(queue: "queue", qos: [option_1: 1, option_2: 2]) 127 | assert message =~ "unknown options [:option_1, :option_2]" 128 | assert message =~ "in options [:qos]" 129 | end 130 | 131 | test "unsupported options for :declare" do 132 | assert {:error, message} = 133 | AmqpClient.init(queue: "queue", declare: [option_1: 1, option_2: 2]) 134 | 135 | assert message =~ "unknown options [:option_1, :option_2]" 136 | assert message =~ "in options [:declare]" 137 | end 138 | 139 | test ":queue is required" do 140 | assert AmqpClient.init([]) == 141 | {:error, "required :queue option not found, received options: []"} 142 | 143 | assert AmqpClient.init(queue: nil) == 144 | {:error, "invalid value for :queue option: expected string, got: nil"} 145 | end 146 | 147 | test ":queue should be a string" do 148 | assert AmqpClient.init(queue: :an_atom) == 149 | {:error, "invalid value for :queue option: expected string, got: :an_atom"} 150 | 151 | {:ok, config} = AmqpClient.init(queue: "my_queue") 152 | assert config.queue == "my_queue" 153 | end 154 | 155 | test ":queue shouldn't be an empty string if :declare is not present" do 156 | assert AmqpClient.init(queue: "") == 157 | {:error, 158 | "can't use \"\" (server autogenerate) as the queue name without the :declare"} 159 | 160 | assert {:ok, config} = AmqpClient.init(queue: "", declare: []) 161 | assert config.queue == "" 162 | end 163 | 164 | test ":metadata should be a list of atoms" do 165 | {:ok, opts} = AmqpClient.init(queue: "queue", metadata: [:routing_key, :headers]) 166 | assert opts[:metadata] == [:routing_key, :headers] 167 | 168 | message = """ 169 | invalid list in :metadata option: invalid value for list element at position 0: expected \ 170 | atom, got: "routing_key"\ 171 | """ 172 | 173 | assert AmqpClient.init(queue: "queue", metadata: ["routing_key", :headers]) == 174 | {:error, message} 175 | end 176 | 177 | test ":bindings should be a list of tuples" do 178 | {:ok, opts} = AmqpClient.init(queue: "queue", bindings: [{"my-exchange", [arguments: []]}]) 179 | assert opts[:bindings] == [{"my-exchange", [arguments: []]}] 180 | 181 | message = """ 182 | invalid list in :bindings option: invalid value for list element at position 0: \ 183 | expected binding to be a {exchange, opts} tuple, got: :something\ 184 | """ 185 | 186 | assert AmqpClient.init(queue: "queue", bindings: [:something, :else]) == 187 | {:error, message} 188 | end 189 | 190 | test ":bindings with invalid binding options" do 191 | message = """ 192 | invalid list in :bindings option: invalid value for list element at position 0: \ 193 | unknown options [:invalid], valid options are: [:routing_key, :arguments]\ 194 | """ 195 | 196 | assert AmqpClient.init(queue: "queue", bindings: [{"my-exchange", [invalid: true]}]) == 197 | {:error, message} 198 | end 199 | 200 | test ":merge_options options should be merged with normal opts" do 201 | merge_options_fun = fn index -> [queue: "queue#{index}"] end 202 | 203 | assert {:ok, %{queue: "queue4"}} = 204 | AmqpClient.init( 205 | queue: "queue", 206 | broadway: [index: 4], 207 | merge_options: merge_options_fun 208 | ) 209 | end 210 | 211 | test ":merge_options doesn't perform deep merging" do 212 | merge_options_fun = fn index -> 213 | [connection: [username: "user#{index}"]] 214 | end 215 | 216 | assert {:ok, %{connection: connection}} = 217 | AmqpClient.init( 218 | queue: "queue", 219 | broadway: [index: 4], 220 | connection: [host: "example.com"], 221 | merge_options: merge_options_fun 222 | ) 223 | 224 | assert connection == [username: "user4"] 225 | end 226 | 227 | test ":merge_options should be a 1-arity function" do 228 | assert AmqpClient.init(queue: "queue", merge_options: :wat) == 229 | {:error, ":merge_options must be a function with arity 1, got: :wat"} 230 | end 231 | 232 | test ":merge_options should return a keyword list" do 233 | assert AmqpClient.init( 234 | queue: "queue", 235 | broadway: [index: 4], 236 | merge_options: fn _index -> :ok end 237 | ) == 238 | {:error, "The :merge_options function should return a keyword list, got: :ok"} 239 | end 240 | 241 | test "options returned by :merge_options are still validated" do 242 | merge_options_fun = fn _index -> [option: 1] end 243 | 244 | assert {:error, message} = 245 | AmqpClient.init( 246 | queue: "queue", 247 | merge_options: merge_options_fun, 248 | broadway: [index: 4] 249 | ) 250 | 251 | assert message =~ "unknown options [:option], valid options are" 252 | end 253 | 254 | test ":merge_options raises if there's no Broadway index in the options" do 255 | merge_options_fun = fn _index -> [option: 1] end 256 | 257 | assert_raise RuntimeError, "missing broadway index", fn -> 258 | AmqpClient.init( 259 | queue: "queue", 260 | merge_options: merge_options_fun, 261 | broadway: [] 262 | ) 263 | end 264 | 265 | assert_raise RuntimeError, "missing broadway index", fn -> 266 | AmqpClient.init( 267 | queue: "queue", 268 | merge_options: merge_options_fun 269 | ) 270 | end 271 | end 272 | end 273 | 274 | test "ack/2 when the channel is down" do 275 | {pid, ref} = spawn_monitor(fn -> :ok end) 276 | assert_receive {:DOWN, ^ref, _, _, _} 277 | 278 | assert {:error, :noproc} = AmqpClient.ack(%AMQP.Channel{pid: pid}, "delivery-tag-1234") 279 | end 280 | 281 | test "reject/2 when the channel is down" do 282 | {pid, ref} = spawn_monitor(fn -> :ok end) 283 | assert_receive {:DOWN, ^ref, _, _, _} 284 | 285 | assert {:error, :noproc} = 286 | AmqpClient.reject(%AMQP.Channel{pid: pid}, "delivery-tag-1234", requeue: false) 287 | end 288 | 289 | describe "setup_channel/1" do 290 | @describetag :integration 291 | 292 | test "returns a real channel" do 293 | {:ok, config} = AmqpClient.init(queue: "queue", declare: [auto_delete: true]) 294 | assert {:ok, %AMQP.Channel{} = channel} = AmqpClient.setup_channel(config) 295 | 296 | # Make sure that the channel is real by issuing a real AMQP operation. 297 | assert {:ok, %{queue: "queue"}} = AMQP.Queue.status(channel, "queue") 298 | 299 | AMQP.Channel.close(channel) 300 | Process.unlink(channel.conn.pid) 301 | AMQP.Connection.close(channel.conn) 302 | end 303 | 304 | test "uses an existing queue if :declare is not specified" do 305 | {:ok, control_channel} = open_channel() 306 | {:ok, %{queue: queue}} = AMQP.Queue.declare(control_channel, "", auto_delete: true) 307 | 308 | {:ok, config} = AmqpClient.init(queue: queue) 309 | assert {:ok, %AMQP.Channel{} = channel} = AmqpClient.setup_channel(config) 310 | assert {:ok, %{queue: ^queue}} = AMQP.Queue.status(channel, queue) 311 | 312 | AMQP.Channel.close(channel) 313 | Process.unlink(channel.conn.pid) 314 | AMQP.Connection.close(channel.conn) 315 | end 316 | 317 | test "can bind the given queue to things" do 318 | {:ok, control_channel} = open_channel() 319 | {:ok, %{queue: queue}} = AMQP.Queue.declare(control_channel, "", auto_delete: true) 320 | 321 | {:ok, config} = AmqpClient.init(queue: queue, bindings: [{"amq.direct", []}]) 322 | assert {:ok, %AMQP.Channel{} = channel} = AmqpClient.setup_channel(config) 323 | 324 | # Consume from the queue. 325 | assert {:ok, consumer_tag} = AMQP.Basic.consume(channel, queue, self()) 326 | assert_receive {:basic_consume_ok, %{consumer_tag: ^consumer_tag}} 327 | 328 | # Check that if we publish to the exchange, we get the message because the binding 329 | # actually happened. 330 | :ok = AMQP.Basic.publish(control_channel, "", _routing_key = queue, "hello") 331 | assert_receive {:basic_deliver, "hello", _meta}, 1000 332 | 333 | AMQP.Channel.close(channel) 334 | Process.unlink(channel.conn.pid) 335 | AMQP.Connection.close(channel.conn) 336 | end 337 | 338 | @tag :capture_log 339 | test "raises if :after_connect returns a bad value" do 340 | {:ok, config} = 341 | AmqpClient.init( 342 | queue: "", 343 | declare: [auto_delete: true], 344 | after_connect: fn _channel -> :bad_return_value end 345 | ) 346 | 347 | message = "unexpected return value from the :after_connect function: :bad_return_value" 348 | assert_raise RuntimeError, message, fn -> AmqpClient.setup_channel(config) end 349 | end 350 | end 351 | 352 | @tag :integration 353 | test "consume/2 + ack/2 + reject/3 + cancel/2" do 354 | {:ok, control_channel} = open_channel() 355 | {:ok, %{queue: queue}} = AMQP.Queue.declare(control_channel, "", auto_delete: true) 356 | 357 | {:ok, config} = AmqpClient.init(queue: queue, bindings: [{"amq.direct", []}]) 358 | assert {:ok, %AMQP.Channel{} = channel} = AmqpClient.setup_channel(config) 359 | 360 | # Consume from the queue. 361 | consumer_tag = AmqpClient.consume(channel, config) 362 | assert_receive {:basic_consume_ok, %{consumer_tag: ^consumer_tag}} 363 | 364 | # Publish a message and ack it. 365 | :ok = AMQP.Basic.publish(control_channel, "", _routing_key = queue, "hello") 366 | assert_receive {:basic_deliver, "hello", %{delivery_tag: delivery_tag}}, 1000 367 | assert :ok = AmqpClient.ack(channel, delivery_tag) 368 | 369 | # Publish a message and reject it. 370 | :ok = AMQP.Basic.publish(control_channel, "", _routing_key = queue, "hello") 371 | assert_receive {:basic_deliver, "hello", %{delivery_tag: delivery_tag}}, 1000 372 | assert :ok = AmqpClient.reject(channel, delivery_tag, requeue: false) 373 | 374 | # Cancel. 375 | assert {:ok, ^consumer_tag} = AmqpClient.cancel(channel, consumer_tag) 376 | assert_receive {:basic_cancel_ok, %{consumer_tag: ^consumer_tag}} 377 | 378 | AMQP.Channel.close(channel) 379 | Process.unlink(channel.conn.pid) 380 | AMQP.Connection.close(channel.conn) 381 | end 382 | 383 | defp open_channel do 384 | {:ok, conn} = AMQP.Connection.open("amqp://localhost") 385 | {:ok, channel} = AMQP.Channel.open(conn) 386 | 387 | on_exit(fn -> 388 | AMQP.Channel.close(channel) 389 | AMQP.Connection.close(conn) 390 | end) 391 | 392 | {:ok, channel} 393 | end 394 | end 395 | -------------------------------------------------------------------------------- /test/broadway_rabbitmq/backoff_test.exs: -------------------------------------------------------------------------------- 1 | defmodule BroadwayRabbitMQ.BackoffTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias BroadwayRabbitMQ.Backoff 5 | 6 | @moduletag backoff_min: 1_000 7 | @moduletag backoff_max: 30_000 8 | 9 | describe "new/1" do 10 | test "without :backoff_min" do 11 | assert %{min: 1000, max: 2000} = new(backoff_type: :exp, backoff_max: 2000) 12 | assert %{min: 500, max: 500} = new(backoff_type: :exp, backoff_max: 500) 13 | end 14 | 15 | test "without :backoff_max" do 16 | assert %{min: 20_000, max: 30_000} = new(backoff_type: :exp, backoff_min: 20_000) 17 | assert %{min: 35_000, max: 35_000} = new(backoff_type: :exp, backoff_min: 35_000) 18 | end 19 | 20 | test "with :backoff_min and :backoff_max" do 21 | min = Enum.random(1..100) 22 | max = Enum.random(101..200) 23 | assert %{min: ^min, max: ^max} = new(backoff_type: :exp, backoff_min: min, backoff_max: max) 24 | end 25 | 26 | test "raises an error if :backoff_min is not an integer or a negative integer" do 27 | assert_raise ArgumentError, "minimum 3.14 not 0 or a positive integer", fn -> 28 | new(backoff_type: :exp, backoff_min: 3.14) 29 | end 30 | 31 | assert_raise ArgumentError, "minimum -1 not 0 or a positive integer", fn -> 32 | new(backoff_type: :exp, backoff_min: -1) 33 | end 34 | end 35 | 36 | test "raises an error if :backoff_max is not an integer or a negative integer" do 37 | assert_raise ArgumentError, "maximum 3.14 not 0 or a positive integer", fn -> 38 | new(backoff_type: :exp, backoff_min: 0, backoff_max: 3.14) 39 | end 40 | 41 | assert_raise ArgumentError, "maximum -1 not 0 or a positive integer", fn -> 42 | new(backoff_type: :exp, backoff_min: 0, backoff_max: -1) 43 | end 44 | end 45 | 46 | test "raises an error if :backoff_min is greater than :backoff_max" do 47 | assert_raise ArgumentError, "minimum 1000 is greater than maximum 500", fn -> 48 | new(backoff_type: :exp, backoff_min: 1000, backoff_max: 500) 49 | end 50 | end 51 | 52 | test "raises if :backoff_type is unknown" do 53 | assert_raise ArgumentError, "unknown type :unknown", fn -> 54 | new(backoff_type: :unknown) 55 | end 56 | end 57 | end 58 | 59 | @tag backoff_type: :exp 60 | test "exponential backoffs always in [min, max]", context do 61 | backoff = new(context) 62 | {delays, _} = backoff(backoff, 20) 63 | 64 | assert Enum.all?(delays, fn delay -> 65 | delay >= context[:backoff_min] and delay <= context[:backoff_max] 66 | end) 67 | end 68 | 69 | @tag backoff_type: :exp 70 | test "exponential backoffs double until max", context do 71 | backoff = new(context) 72 | {delays, _} = backoff(backoff, 20) 73 | 74 | Enum.reduce(delays, fn next, prev -> 75 | assert div(next, 2) == prev or next == context[:backoff_max] 76 | next 77 | end) 78 | end 79 | 80 | @tag backoff_type: :exp 81 | test "exponential backoffs reset to min", context do 82 | backoff = new(context) 83 | {[delay | _], backoff} = backoff(backoff, 20) 84 | assert delay == context[:backoff_min] 85 | 86 | backoff = Backoff.reset(backoff) 87 | {[delay], _} = backoff(backoff, 1) 88 | assert delay == context[:backoff_min] 89 | end 90 | 91 | @tag backoff_type: :rand 92 | test "random backoffs always in [min, max]", context do 93 | backoff = new(context) 94 | {delays, _} = backoff(backoff, 20) 95 | 96 | assert Enum.all?(delays, fn delay -> 97 | delay >= context[:backoff_min] and delay <= context[:backoff_max] 98 | end) 99 | end 100 | 101 | @tag backoff_type: :rand 102 | test "random backoffs are not all the same value", context do 103 | backoff = new(context) 104 | {delays, _} = backoff(backoff, 20) 105 | ## If the stars align this test could fail ;) 106 | refute Enum.all?(delays, &(hd(delays) == &1)) 107 | end 108 | 109 | @tag backoff_type: :rand 110 | test "random backoffs repeat", context do 111 | backoff = new(context) 112 | assert backoff(backoff, 20) == backoff(backoff, 20) 113 | end 114 | 115 | @tag backoff_type: :rand 116 | test "random backoffs reset by not changing anything", context do 117 | backoff = new(context) 118 | assert Backoff.reset(backoff) == backoff 119 | end 120 | 121 | @tag backoff_type: :rand_exp 122 | test "random exponential backoffs always in [min, max]", context do 123 | backoff = new(context) 124 | {delays, _} = backoff(backoff, 20) 125 | 126 | assert Enum.all?(delays, fn delay -> 127 | delay >= context[:backoff_min] and delay <= context[:backoff_max] 128 | end) 129 | end 130 | 131 | @tag backoff_type: :rand_exp 132 | test "random exponential backoffs increase until a third of max", context do 133 | backoff = new(context) 134 | {delays, _} = backoff(backoff, 20) 135 | 136 | Enum.reduce(delays, fn next, prev -> 137 | assert next >= prev or next >= div(context[:backoff_max], 3) 138 | next 139 | end) 140 | end 141 | 142 | @tag backoff_type: :rand_exp 143 | test "random exponential backoffs repeat", context do 144 | backoff = new(context) 145 | assert backoff(backoff, 20) == backoff(backoff, 20) 146 | end 147 | 148 | @tag backoff_type: :rand_exp 149 | test "random exponential backoffs reset in [min, min * 3]", context do 150 | backoff = new(context) 151 | {[delay | _], backoff} = backoff(backoff, 20) 152 | assert delay in context[:backoff_min]..(context[:backoff_min] * 3) 153 | 154 | backoff = Backoff.reset(backoff) 155 | {[delay], _} = backoff(backoff, 1) 156 | assert delay in context[:backoff_min]..(context[:backoff_min] * 3) 157 | end 158 | 159 | ## Helpers 160 | 161 | def new(context) do 162 | Backoff.new(Enum.into(context, [])) 163 | end 164 | 165 | defp backoff(backoff, n) do 166 | Enum.map_reduce(1..n, backoff, fn _, acc -> Backoff.backoff(acc) end) 167 | end 168 | end 169 | -------------------------------------------------------------------------------- /test/broadway_rabbitmq/producer_test.exs: -------------------------------------------------------------------------------- 1 | defmodule BroadwayRabbitMQ.ProducerTest do 2 | use ExUnit.Case 3 | 4 | import ExUnit.CaptureLog 5 | import ExUnit.CaptureIO 6 | 7 | alias Broadway.Message 8 | alias BroadwayRabbitMQ.Producer 9 | 10 | defmodule FakeChannel do 11 | use GenServer 12 | 13 | def new(test_pid) do 14 | fake_connection_pid = spawn(fn -> Process.sleep(:infinity) end) 15 | true = Process.link(fake_connection_pid) 16 | {:ok, fake_channel_pid} = GenServer.start(__MODULE__, fake_connection_pid) 17 | 18 | %{ 19 | pid: fake_channel_pid, 20 | conn: %{pid: fake_connection_pid, test_pid: test_pid}, 21 | test_pid: test_pid 22 | } 23 | end 24 | 25 | def init(fake_connection_pid) do 26 | Process.link(fake_connection_pid) 27 | {:ok, :no_state} 28 | end 29 | 30 | def handle_call(:fake_basic_ack, _from, state) do 31 | {:reply, :ok, state} 32 | end 33 | end 34 | 35 | defmodule FakeRabbitmqClient do 36 | @behaviour BroadwayRabbitMQ.RabbitmqClient 37 | 38 | @impl true 39 | def init(opts) do 40 | send(opts[:test_pid], :init_called) 41 | {:ok, opts} 42 | end 43 | 44 | @impl true 45 | def setup_channel(config) do 46 | test_pid = config[:test_pid] 47 | 48 | status = 49 | Agent.get_and_update(config[:connection_agent], fn 50 | [status | rest] -> 51 | {status, rest} 52 | 53 | _ -> 54 | {:ok, []} 55 | end) 56 | 57 | if status == :ok do 58 | channel = FakeChannel.new(test_pid) 59 | send(test_pid, {:setup_channel, :ok, channel}) 60 | {:ok, channel} 61 | else 62 | send(test_pid, {:setup_channel, :error, nil}) 63 | {:error, status} 64 | end 65 | end 66 | 67 | @impl true 68 | def ack(channel, delivery_tag) do 69 | GenServer.call(channel.pid, :fake_basic_ack) 70 | send(channel.test_pid, {:ack, delivery_tag}) 71 | :ok 72 | end 73 | 74 | @impl true 75 | def reject(channel, delivery_tag, opts) do 76 | GenServer.call(channel.pid, :fake_basic_ack) 77 | send(channel.test_pid, {:reject, delivery_tag, opts}) 78 | :ok 79 | end 80 | 81 | @impl true 82 | def consume(_channel, _config) do 83 | send(self(), {:basic_consume_ok, %{consumer_tag: :fake_consumer_tag}}) 84 | :fake_consumer_tag 85 | end 86 | 87 | @impl true 88 | def cancel(_channel, :fake_consumer_tag_closing) do 89 | {:error, :closing} 90 | end 91 | 92 | @impl true 93 | def cancel(%{test_pid: test_pid}, consumer_tag) do 94 | send(test_pid, {:cancel, consumer_tag}) 95 | {:ok, consumer_tag} 96 | end 97 | 98 | @impl true 99 | def close_connection(_config, %{test_pid: test_pid}) do 100 | send(test_pid, :connection_closed) 101 | :ok 102 | end 103 | end 104 | 105 | defmodule FlakyRabbitmqClient do 106 | @behaviour BroadwayRabbitMQ.RabbitmqClient 107 | 108 | @impl true 109 | def init(opts) do 110 | send(opts[:test_pid], :init_called) 111 | {:ok, opts} 112 | end 113 | 114 | @impl true 115 | def setup_channel(config) do 116 | test_pid = config[:test_pid] 117 | 118 | status = 119 | Agent.get_and_update(config[:connection_agent], fn 120 | [status | rest] -> 121 | {status, rest} 122 | 123 | _ -> 124 | {:ok, []} 125 | end) 126 | 127 | if status == :ok do 128 | channel = FakeChannel.new(test_pid) 129 | send(test_pid, {:setup_channel, :ok, channel}) 130 | {:ok, channel} 131 | else 132 | send(test_pid, {:setup_channel, :error, nil}) 133 | {:error, :econnrefused} 134 | end 135 | end 136 | 137 | @impl true 138 | def ack(_channel, :error_tuple) do 139 | {:error, "Cannot acknowledge, error returned from amqp"} 140 | end 141 | 142 | @impl true 143 | def ack(_channel, delivery_tag) do 144 | raise "cannot acknowledge with delivery tag: #{inspect(delivery_tag)}" 145 | end 146 | 147 | @impl true 148 | def reject(channel, delivery_tag, opts) do 149 | GenServer.call(channel.pid, :fake_basic_ack) 150 | send(channel.test_pid, {:reject, delivery_tag, opts}) 151 | end 152 | 153 | @impl true 154 | def consume(_channel, _config) do 155 | :fake_consumer_tag 156 | end 157 | 158 | @impl true 159 | def cancel(_channel, :fake_consumer_tag_closing) do 160 | {:error, :closing} 161 | end 162 | 163 | @impl true 164 | def cancel(%{test_pid: test_pid}, consumer_tag) do 165 | send(test_pid, {:cancel, consumer_tag}) 166 | {:ok, consumer_tag} 167 | end 168 | 169 | @impl true 170 | def close_connection(_config, %{test_pid: test_pid}) do 171 | send(test_pid, :connection_closed) 172 | :ok 173 | end 174 | end 175 | 176 | defmodule Forwarder do 177 | use Broadway 178 | 179 | def handle_message(_, message, %{test_pid: test_pid}) do 180 | channel = get_channel(message) 181 | send(test_pid, {:message_handled, message, channel}) 182 | 183 | case message.data do 184 | :fail -> 185 | Message.failed(message, "failed") 186 | 187 | :break_conn -> 188 | Process.exit(channel.conn.pid, :shutdown) 189 | message 190 | 191 | {:configure, options, :fail} -> 192 | message 193 | |> Message.configure_ack(options) 194 | |> Message.failed("failed") 195 | 196 | {:configure, options, _} -> 197 | Message.configure_ack(message, options) 198 | 199 | _ -> 200 | message 201 | end 202 | end 203 | 204 | def handle_batch(_, messages, _, %{test_pid: test_pid}) do 205 | send(test_pid, {:batch_handled, Enum.map(messages, & &1.data)}) 206 | messages 207 | end 208 | 209 | defp get_channel(%Message{acknowledger: {_, channel, _}}) do 210 | channel 211 | end 212 | end 213 | 214 | defmodule CustomPool do 215 | @behaviour BroadwayRabbitMQ.ChannelPool 216 | 217 | @impl true 218 | def checkout_channel(parent) do 219 | channel = %AMQP.Channel{pid: spawn(fn -> Process.sleep(:infinity) end)} 220 | send(parent, {:checkout_channel_called, channel}) 221 | {:ok, channel} 222 | end 223 | 224 | @impl true 225 | def checkin_channel(parent, %AMQP.Channel{} = channel) do 226 | send(parent, {:checkin_channel_called, channel}) 227 | Process.exit(channel.pid, :kill) 228 | :ok 229 | end 230 | end 231 | 232 | test "raise an ArgumentError with proper message when client options are invalid" do 233 | message = ~r/invalid value for :queue option: expected string, got: nil/ 234 | 235 | assert_raise ArgumentError, message, fn -> 236 | BroadwayRabbitMQ.Producer.init(queue: nil, on_failure: :reject_and_requeue) 237 | end 238 | end 239 | 240 | test "raise an ArgumentError with proper message when backoff options are invalid" do 241 | assert_raise ArgumentError, ~r/invalid value for :backoff_type/, fn -> 242 | BroadwayRabbitMQ.Producer.init( 243 | queue: "test", 244 | backoff_type: :unknown_type, 245 | on_failure: :reject_and_requeue 246 | ) 247 | end 248 | end 249 | 250 | test "prints a deprecation warning when :on_failure is not specified" do 251 | stderr = 252 | capture_io(:stderr, fn -> 253 | BroadwayRabbitMQ.Producer.init(queue: "test") 254 | end) 255 | 256 | assert stderr =~ ":on_failure should be specified" 257 | end 258 | 259 | test "producer :buffer_size is :prefetch_count * 5" do 260 | qos = [prefetch_count: 12] 261 | 262 | {:producer, _, options} = 263 | BroadwayRabbitMQ.Producer.init(queue: "test", qos: qos, on_failure: :reject_and_requeue) 264 | 265 | assert options[:buffer_size] == 60 266 | end 267 | 268 | test "producer :buffer_size and :buffer_keep can be overridden" do 269 | {:producer, _, options} = 270 | BroadwayRabbitMQ.Producer.init( 271 | queue: "test", 272 | qos: [prefetch_count: 12], 273 | buffer_size: 100, 274 | buffer_keep: :first, 275 | on_failure: :reject_and_requeue 276 | ) 277 | 278 | assert options[:buffer_size] == 100 279 | assert options[:buffer_keep] == :first 280 | end 281 | 282 | test ":prefetch_count set to 0 requires explicit :buffer_size setting" do 283 | assert_raise ArgumentError, ":prefetch_count is 0, specify :buffer_size explicitly", fn -> 284 | BroadwayRabbitMQ.Producer.init( 285 | queue: "test", 286 | qos: [prefetch_count: 0], 287 | on_failure: :reject_and_requeue 288 | ) 289 | end 290 | 291 | {:producer, _, options} = 292 | BroadwayRabbitMQ.Producer.init( 293 | queue: "test", 294 | qos: [prefetch_count: 0], 295 | buffer_size: 100, 296 | on_failure: :reject_and_requeue 297 | ) 298 | 299 | assert options[:buffer_size] == 100 300 | end 301 | 302 | test "retrieve only selected metadata" do 303 | broadway = start_broadway(metadata: [:routing_key, :content_type]) 304 | 305 | deliver_messages(broadway, 1..2, 306 | extra_metadata: %{ 307 | routing_key: "FAKE_ROTING_KEY", 308 | headers: "FAKE_HEADERS", 309 | content_type: "FAKE_CONTENT_TYPE", 310 | expiration: "FAKE_EXPIRATION" 311 | } 312 | ) 313 | 314 | assert_receive {:message_handled, %Message{metadata: meta}, _} 315 | assert map_size(meta) == 3 316 | 317 | assert %{content_type: "FAKE_CONTENT_TYPE", routing_key: "FAKE_ROTING_KEY", amqp_channel: %{}} = 318 | meta 319 | end 320 | 321 | test "forward messages delivered by the channel" do 322 | broadway = start_broadway() 323 | 324 | deliver_messages(broadway, 1..4) 325 | 326 | assert_receive {:batch_handled, [1, 2]} 327 | assert_receive {:batch_handled, [3, 4]} 328 | 329 | stop_broadway(broadway) 330 | end 331 | 332 | test "acknowledge/reject processed messages" do 333 | broadway = start_broadway() 334 | 335 | deliver_messages(broadway, [1, 2, :fail, 4, 5]) 336 | 337 | assert_receive {:ack, 1} 338 | assert_receive {:ack, 2} 339 | assert_receive {:reject, :fail, _} 340 | assert_receive {:ack, 4} 341 | assert_receive {:ack, 5} 342 | 343 | stop_broadway(broadway) 344 | end 345 | 346 | describe "controlling on_success/on_failure behavior" do 347 | test "setting on_success/on_failure when starting the producer" do 348 | broadway = start_broadway(on_success: :reject, on_failure: :ack) 349 | 350 | deliver_messages(broadway, [1, 2, :fail, 4]) 351 | 352 | assert_receive {:reject, 1, _} 353 | assert_receive {:reject, 2, _} 354 | assert_receive {:ack, :fail} 355 | assert_receive {:reject, 4, _} 356 | end 357 | 358 | test "overriding on_success/on_failure through Broadway.Message.configure_ack/2" do 359 | broadway = start_broadway() 360 | 361 | deliver_messages(broadway, [ 362 | {:configure, [on_success: :reject], 1}, 363 | {:configure, [on_failure: :ack], :fail} 364 | ]) 365 | 366 | assert_receive {:reject, {:configure, _opts, 1}, _} 367 | assert_receive {:ack, {:configure, _opts, :fail}} 368 | end 369 | 370 | test "passing unsupported options to Broadway.Message.configure_ack/2" do 371 | broadway = start_broadway() 372 | 373 | log = 374 | capture_log(fn -> 375 | deliver_messages(broadway, [{:configure, [unknown: 1], 1}]) 376 | 377 | assert_receive {:reject, {:configure, _opts, 1}, _} 378 | end) 379 | 380 | assert log =~ "unsupported configure option :unknown" 381 | end 382 | 383 | test "setting on_success/on_failure to an unsupported value raises an error" do 384 | broadway = start_broadway() 385 | 386 | log = 387 | capture_log(fn -> 388 | deliver_messages(broadway, [{:configure, [on_success: :wat], 1}]) 389 | 390 | assert_receive {:reject, {:configure, _opts, 1}, _} 391 | end) 392 | 393 | assert log =~ "unsupported value for :on_success option: :wat" 394 | end 395 | end 396 | 397 | describe "handle requeuing with the :on_success/:on_failure options" do 398 | test "always requeue messages with :on_failure set to :reject_and_requeue" do 399 | broadway = start_broadway(on_failure: :reject_and_requeue) 400 | 401 | deliver_messages(broadway, [1, :fail], redelivered: true) 402 | assert_receive {:reject, :fail, _opts} 403 | 404 | deliver_messages(broadway, [2, :fail], redelivered: false) 405 | assert_receive {:reject, :fail, _opts} 406 | 407 | refute_receive {:reject, :fail, _} 408 | 409 | stop_broadway(broadway) 410 | end 411 | 412 | test "never requeue messages with :on_failure set to :reject" do 413 | broadway = start_broadway(on_failure: :reject) 414 | 415 | deliver_messages(broadway, [1, :fail], redelivered: true) 416 | assert_receive {:reject, :fail, opts} 417 | assert opts[:requeue] == false 418 | 419 | deliver_messages(broadway, [2, :fail], redelivered: false) 420 | assert_receive {:reject, :fail, opts} 421 | assert opts[:requeue] == false 422 | 423 | refute_receive {:reject, :fail, _} 424 | 425 | stop_broadway(broadway) 426 | end 427 | 428 | test "requeue messages unless it's been redelivered with :on_failure set to :reject_and_requeue_once" do 429 | broadway = start_broadway(on_failure: :reject_and_requeue_once) 430 | 431 | deliver_messages(broadway, [1, :fail], redelivered: true) 432 | assert_receive {:reject, :fail, _opts} 433 | 434 | deliver_messages(broadway, [2, :fail], redelivered: false) 435 | assert_receive {:reject, :fail, _opts} 436 | 437 | refute_receive {:reject, :fail, _} 438 | 439 | stop_broadway(broadway) 440 | end 441 | end 442 | 443 | test "if the :no_ack consume option is true, the acknowledger is set to NoopAcknowledger" do 444 | broadway = start_broadway(consume_options: [no_ack: true]) 445 | assert_receive {:setup_channel, :ok, _} 446 | 447 | deliver_messages(broadway, [1]) 448 | 449 | assert_receive {:message_handled, %Broadway.Message{} = message, _channel} 450 | assert message.acknowledger == {Broadway.NoopAcknowledger, nil, nil} 451 | end 452 | 453 | test "handles the :basic_consume_ok message when consuming" do 454 | broadway = start_broadway() 455 | assert_receive {:setup_channel, :ok, channel} 456 | 457 | deliver_messages(broadway, [1]) 458 | assert_receive {:message_handled, %Broadway.Message{}, ^channel} 459 | end 460 | 461 | describe "prepare_for_draining" do 462 | test "cancel consumer and keep the current state" do 463 | channel = FakeChannel.new(self()) 464 | tag = :fake_consumer_tag 465 | state = %{client: FakeRabbitmqClient, channel: channel, consumer_tag: tag} 466 | 467 | assert {:noreply, [], ^state} = BroadwayRabbitMQ.Producer.prepare_for_draining(state) 468 | assert_received {:cancel, ^tag} 469 | end 470 | 471 | test "log unsuccessful cancellation and keep the current state" do 472 | channel = FakeChannel.new(self()) 473 | tag = :fake_consumer_tag_closing 474 | state = %{client: FakeRabbitmqClient, channel: channel, consumer_tag: tag} 475 | 476 | assert( 477 | capture_log(fn -> 478 | assert {:noreply, [], ^state} = BroadwayRabbitMQ.Producer.prepare_for_draining(state) 479 | end) 480 | ) =~ "[error] Could not cancel producer while draining. Channel is closing" 481 | end 482 | end 483 | 484 | describe "handle connection loss" do 485 | test "producer is not restarted" do 486 | broadway = start_broadway() 487 | assert_receive {:setup_channel, :ok, _} 488 | producer_1 = get_producer(broadway) 489 | 490 | deliver_messages(broadway, [1, :break_conn]) 491 | assert_receive {:setup_channel, :ok, _} 492 | producer_2 = get_producer(broadway) 493 | 494 | assert producer_1 == producer_2 495 | 496 | stop_broadway(broadway) 497 | end 498 | 499 | test "open a new connection/channel and keep consuming messages" do 500 | broadway = start_broadway() 501 | assert_receive {:setup_channel, :ok, channel_1} 502 | 503 | deliver_messages(broadway, [1, 2]) 504 | assert_receive {:message_handled, %Message{data: 1}, ^channel_1} 505 | assert_receive {:message_handled, %Message{data: 2}, ^channel_1} 506 | 507 | deliver_messages(broadway, [:break_conn]) 508 | assert_receive {:setup_channel, :ok, channel_2} 509 | 510 | deliver_messages(broadway, [3, 4]) 511 | assert_receive {:message_handled, %Message{data: 3}, ^channel_2} 512 | assert_receive {:message_handled, %Message{data: 4}, ^channel_2} 513 | 514 | assert channel_1.pid != channel_2.pid 515 | assert channel_1.conn.pid != channel_2.conn.pid 516 | 517 | stop_broadway(broadway) 518 | end 519 | 520 | test "processed messages delivered by the old connection/channel will not be acknowledged" do 521 | broadway = start_broadway() 522 | assert_receive {:setup_channel, :ok, channel} 523 | 524 | deliver_messages(broadway, [1, :break_conn]) 525 | 526 | assert_receive {:message_handled, %Message{data: 1}, ^channel} 527 | assert_receive {:message_handled, %Message{data: :break_conn}, ^channel} 528 | 529 | refute_receive {:ack, 1} 530 | refute_receive {:ack, :break_conn} 531 | end 532 | 533 | test "log error when trying to acknowledge" do 534 | broadway = start_broadway() 535 | assert_receive {:setup_channel, :ok, _channel} 536 | 537 | assert capture_log(fn -> 538 | deliver_messages(broadway, [:break_conn]) 539 | refute_receive {:ack, :break_conn} 540 | end) =~ "(EXIT) no process: the process is not alive" 541 | 542 | stop_broadway(broadway) 543 | end 544 | 545 | test "client is reinitialized every time it reconnects (for example, for switching URLs)" do 546 | broadway = start_broadway() 547 | 548 | assert_receive {:setup_channel, :ok, _} 549 | assert_receive :init_called 550 | 551 | deliver_messages(broadway, [1, :break_conn]) 552 | assert_receive {:setup_channel, :ok, _} 553 | assert_receive :init_called 554 | 555 | stop_broadway(broadway) 556 | end 557 | end 558 | 559 | describe "handle consumer cancellation" do 560 | test "open a new connection/channel and keep consuming messages" do 561 | broadway = start_broadway() 562 | assert_receive {:setup_channel, :ok, channel_1} 563 | 564 | producer = get_producer(broadway) 565 | 566 | send(producer, {:basic_cancel, %{consumer_tag: :fake_consumer_tag}}) 567 | 568 | assert_receive {:setup_channel, :ok, channel_2} 569 | 570 | assert channel_1.pid != channel_2.pid 571 | assert channel_1.conn.pid != channel_2.conn.pid 572 | 573 | stop_broadway(broadway) 574 | end 575 | 576 | test "dealing with :basic_consume_ok messages" do 577 | broadway = start_broadway() 578 | assert_receive {:setup_channel, :ok, _channel} 579 | 580 | producer = get_producer(broadway) 581 | 582 | send(producer, {:basic_cancel_ok, %{consumer_tag: :fake_consumer_tag}}) 583 | 584 | stop_broadway(broadway) 585 | end 586 | end 587 | 588 | describe "handle connection refused" do 589 | test "log the error and try to reconnect" do 590 | assert capture_log(fn -> 591 | broadway = start_broadway(connect_responses: [:econnrefused]) 592 | assert_receive {:setup_channel, :error, _} 593 | assert_receive {:setup_channel, :ok, _} 594 | stop_broadway(broadway) 595 | end) =~ "Cannot connect to RabbitMQ broker" 596 | end 597 | 598 | @tag :capture_log 599 | test "emit a Telemetry event" do 600 | parent = self() 601 | ref = make_ref() 602 | 603 | :telemetry.attach( 604 | "handle-connection-refused-emit-telemetry-event", 605 | [:broadway_rabbitmq, :amqp, :connection_failure], 606 | fn _event, measurement, meta, _config -> send(parent, {ref, measurement, meta}) end, 607 | _config = nil 608 | ) 609 | 610 | broadway = start_broadway(connect_responses: [:econnrefused]) 611 | assert_receive {:setup_channel, :error, _} 612 | assert_receive {:setup_channel, :ok, _} 613 | 614 | assert_receive {^ref, measurement, meta}, 500 615 | assert %{system_time: _} = measurement 616 | assert meta == %{reason: :econnrefused} 617 | 618 | stop_broadway(broadway) 619 | after 620 | :telemetry.detach("handle-connection-refused-emit-telemetry-event") 621 | end 622 | 623 | test "if backoff_type = :stop, log the error and don't try to reconnect" do 624 | assert capture_log(fn -> 625 | broadway = start_broadway(connect_responses: [:econnrefused], backoff_type: :stop) 626 | assert_receive {:setup_channel, :error, _} 627 | refute_receive {:setup_channel, _, _} 628 | stop_broadway(broadway) 629 | end) =~ "Cannot connect to RabbitMQ broker" 630 | end 631 | 632 | test "keep retrying to connect using the backoff strategy" do 633 | broadway = 634 | start_broadway(connect_responses: [:ok, :econnrefused, :econnrefused, :econnrefused, :ok]) 635 | 636 | assert_receive {:setup_channel, :ok, _} 637 | 638 | deliver_messages(broadway, [1, :break_conn]) 639 | 640 | assert_receive {:setup_channel, :error, _} 641 | assert get_backoff_timeout(broadway) == 10 642 | assert_receive {:setup_channel, :error, _} 643 | assert get_backoff_timeout(broadway) == 20 644 | assert_receive {:setup_channel, :error, _} 645 | assert get_backoff_timeout(broadway) == 40 646 | 647 | assert_receive {:setup_channel, :ok, _} 648 | refute_receive {:setup_channel, _, _} 649 | 650 | stop_broadway(broadway) 651 | end 652 | 653 | test "reset backoff timeout after a successful connection" do 654 | broadway = start_broadway(connect_responses: [:econnrefused, :ok]) 655 | 656 | assert_receive {:setup_channel, :error, _} 657 | assert get_backoff_timeout(broadway) == 10 658 | 659 | assert_receive {:setup_channel, :ok, _} 660 | assert get_backoff_timeout(broadway) == nil 661 | 662 | stop_broadway(broadway) 663 | end 664 | 665 | test "with auth_failure 'Disconnected'" do 666 | log = 667 | capture_log(fn -> 668 | broadway = start_broadway(connect_responses: [{:auth_failure, ~c"Disconnected"}]) 669 | assert_receive {:setup_channel, :error, _} 670 | assert_receive {:setup_channel, :ok, _} 671 | stop_broadway(broadway) 672 | end) 673 | 674 | assert log =~ "Cannot connect to RabbitMQ broker: {:auth_failure, 'Disconnected'}" or 675 | log =~ "Cannot connect to RabbitMQ broker: {:auth_failure, ~c\"Disconnected\"}" 676 | end 677 | 678 | test "with socket_closed_unexpectedly" do 679 | assert capture_log(fn -> 680 | broadway = 681 | start_broadway( 682 | connect_responses: [{:socket_closed_unexpectedly, :"connection.start"}] 683 | ) 684 | 685 | assert_receive {:setup_channel, :error, _} 686 | assert_receive {:setup_channel, :ok, _} 687 | stop_broadway(broadway) 688 | end) =~ "Cannot connect to RabbitMQ broker: {:socket_closed_unexpectedly" 689 | end 690 | end 691 | 692 | describe "unsuccessful acknowledgement" do 693 | test "raise when an error is thrown acknowledging" do 694 | assert_raise(RuntimeError, fn -> 695 | Message.ack_immediately(%Message{ 696 | data: :fail_to_ack, 697 | acknowledger: 698 | {Producer, {self(), :ref}, 699 | %{ 700 | client: FlakyRabbitmqClient, 701 | on_success: :ack, 702 | on_failure: :reject, 703 | delivery_tag: :unused 704 | }} 705 | }) 706 | end) 707 | end 708 | 709 | test "raise when an error is returned from amqp" do 710 | msgs = 711 | Enum.map(["failure one", "failure two"], fn data -> 712 | %Message{ 713 | data: data, 714 | acknowledger: 715 | {Producer, {self(), :ref}, 716 | %{ 717 | client: FlakyRabbitmqClient, 718 | on_success: :ack, 719 | on_failure: :reject, 720 | delivery_tag: :error_tuple 721 | }} 722 | } 723 | end) 724 | 725 | ack_attempt = fn -> 726 | Message.ack_immediately(msgs) 727 | end 728 | 729 | assert_raise(RuntimeError, fn -> 730 | assert capture_log(ack_attempt) =~ "failure one" 731 | assert capture_log(ack_attempt) =~ "failure two" 732 | assert capture_log(ack_attempt) =~ "error returned from amqp" 733 | end) 734 | end 735 | end 736 | 737 | test "close connection on terminate" do 738 | Process.flag(:trap_exit, true) 739 | broadway = start_broadway() 740 | assert_receive {:setup_channel, :ok, _channel} 741 | Process.exit(Process.whereis(broadway), :shutdown) 742 | assert_receive :connection_closed 743 | end 744 | 745 | test "if connection goes down, we reconnect" do 746 | _broadway = start_broadway() 747 | assert_receive {:setup_channel, :ok, channel} 748 | 749 | assert capture_log(fn -> 750 | Process.exit(channel.conn.pid, :shutdown) 751 | assert_receive {:setup_channel, :ok, new_channel} 752 | assert new_channel.pid != channel.pid 753 | end) =~ "AMQP connection went down with reason: :shutdown" 754 | end 755 | 756 | test "if channel goes down, we close the connection before reconnecting" do 757 | _broadway = start_broadway() 758 | assert_receive {:setup_channel, :ok, channel} 759 | 760 | Process.exit(channel.pid, :shutdown) 761 | assert_receive :connection_closed 762 | end 763 | 764 | test "if producer gets an AMQP basic_cancel, we disconnect" do 765 | broadway = start_broadway() 766 | producer = Broadway.producer_names(broadway) |> Enum.random() 767 | assert_receive {:setup_channel, :ok, _channel} 768 | send(producer, {:basic_cancel, %{consumer_tag: :fake_consumer_tag}}) 769 | assert_receive :connection_closed 770 | end 771 | 772 | describe "with custom pool" do 773 | @tag :capture_log 774 | test "calls the pool to check out a channel" do 775 | broadway = 776 | start_broadway( 777 | connection: {:custom_pool, CustomPool, self()}, 778 | client: BroadwayRabbitMQ.AmqpClient, 779 | after_connect: fn _channel -> {:error, :some_reason} end 780 | ) 781 | 782 | assert_receive {:checkout_channel_called, new_channel} 783 | assert_receive {:checkin_channel_called, ^new_channel} 784 | 785 | stop_broadway(broadway) 786 | end 787 | end 788 | 789 | defp start_broadway(opts \\ []) do 790 | connect_responses = Keyword.get(opts, :connect_responses, []) 791 | backoff_type = Keyword.get(opts, :backoff_type, :exp) 792 | metadata = Keyword.get(opts, :metadata, []) 793 | on_success = Keyword.get(opts, :on_success, :ack) 794 | on_failure = Keyword.get(opts, :on_failure, :reject) 795 | merge_options = Keyword.get(opts, :merge_options, fn _ -> [] end) 796 | client = Keyword.get(opts, :client, FakeRabbitmqClient) 797 | consume_options = Keyword.get(opts, :consume_options, []) 798 | connection = Keyword.get(opts, :connection, nil) 799 | after_connect = Keyword.get(opts, :after_connect, nil) 800 | 801 | {:ok, connection_agent} = Agent.start_link(fn -> connect_responses end) 802 | name = new_unique_name() 803 | 804 | producer_opts = [ 805 | client: client, 806 | queue: "test", 807 | test_pid: self(), 808 | backoff_type: backoff_type, 809 | backoff_min: 10, 810 | backoff_max: 100, 811 | connection_agent: connection_agent, 812 | qos: [prefetch_count: 10], 813 | metadata: metadata, 814 | consume_options: consume_options, 815 | on_success: on_success, 816 | on_failure: on_failure, 817 | merge_options: merge_options, 818 | connection: connection, 819 | after_connect: after_connect 820 | ] 821 | 822 | producer_opts = 823 | if client == BroadwayRabbitMQ.AmqpClient do 824 | Keyword.drop(producer_opts, [:test_pid, :connection_agent]) 825 | else 826 | producer_opts 827 | end 828 | 829 | {:ok, _pid} = 830 | Broadway.start_link(Forwarder, 831 | name: name, 832 | context: %{test_pid: self()}, 833 | producer: [module: {BroadwayRabbitMQ.Producer, producer_opts}, concurrency: 1], 834 | processors: [default: [concurrency: 1]], 835 | batchers: [default: [batch_size: 2, batch_timeout: 50, concurrency: 1]] 836 | ) 837 | 838 | name 839 | end 840 | 841 | defp new_unique_name() do 842 | :"Broadway#{System.unique_integer([:positive, :monotonic])}" 843 | end 844 | 845 | defp deliver_messages(broadway, messages, opts \\ []) do 846 | redelivered = Keyword.get(opts, :redelivered, false) 847 | producer = Broadway.producer_names(broadway) |> Enum.random() 848 | extra_metadata = Keyword.get(opts, :extra_metadata, %{}) 849 | 850 | Enum.each(messages, fn msg -> 851 | send( 852 | producer, 853 | {:basic_deliver, msg, 854 | Map.merge(%{delivery_tag: msg, redelivered: redelivered}, extra_metadata)} 855 | ) 856 | end) 857 | end 858 | 859 | defp get_producer(name, index \\ 0) when is_atom(name) do 860 | :"#{name}.Broadway.Producer_#{index}" 861 | end 862 | 863 | defp get_backoff_timeout(broadway) do 864 | producer = get_producer(broadway) 865 | :sys.get_state(producer).state.module_state.backoff.state 866 | end 867 | 868 | defp stop_broadway(name) when is_atom(name) do 869 | pid = Process.whereis(name) 870 | ref = Process.monitor(pid) 871 | Process.exit(pid, :normal) 872 | 873 | receive do 874 | {:DOWN, ^ref, _, _, _} -> :ok 875 | after 876 | 1000 -> flunk("Broadway did not stop within 1000ms") 877 | end 878 | end 879 | end 880 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start(capture_log: true) 2 | --------------------------------------------------------------------------------