├── .circleci └── config.yml ├── .gitignore ├── .ruby-gemset ├── .ruby-version ├── .travis.yml ├── CODEOWNERS ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Gemfile ├── Gemfile.rails-6.1 ├── Gemfile.rails-7.0 ├── LICENSE ├── README.md ├── Rakefile ├── build-matrix.json ├── docker-compose.yml ├── docs └── images │ ├── RabbitMQ_Management-2.png │ ├── RabbitMQ_Management-3.png │ └── RabbitMQ_Management.png ├── lib ├── pwwka.rb └── pwwka │ ├── channel_connector.rb │ ├── configuration.rb │ ├── error_handlers.rb │ ├── error_handlers │ ├── base_error_handler.rb │ ├── chain.rb │ ├── crash.rb │ ├── ignore_payload_format_errors.rb │ ├── nack_and_ignore.rb │ └── nack_and_requeue_once.rb │ ├── handling.rb │ ├── logging.rb │ ├── message_queuer.rb │ ├── publish_options.rb │ ├── queue_resque_job_handler.rb │ ├── receiver.rb │ ├── send_message_async_job.rb │ ├── send_message_async_sidekiq_job.rb │ ├── tasks.rb │ ├── test_handler.rb │ ├── transmitter.rb │ └── version.rb ├── owners.json ├── pwwka.gemspec └── spec ├── integration ├── interrupted_receivers_spec.rb ├── send_and_receive_spec.rb ├── support │ ├── integration_test_helpers.rb │ ├── integration_test_setup.rb │ └── logging_receiver.rb ├── test_handler_spec.rb └── unhandled_errors_in_receivers_spec.rb ├── legacy ├── handling_spec.rb ├── receiver_spec.rb ├── send_message_async_job_spec.rb └── transmitter_spec.rb ├── lib └── pwwka │ └── error_handlers │ └── chain_spec.rb ├── spec_helper.rb ├── support └── test_configuration.rb └── unit ├── channel_connector_spec.rb ├── configuration_spec.rb ├── logging_spec.rb ├── message_queuer_spec.rb ├── queue_resque_job_handler_spec.rb ├── receiver_spec.rb ├── send_message_async_job_spec.rb ├── send_message_async_sidekiq_job_spec.rb ├── test_handler_message_spec.rb └── transmitter_spec.rb /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # DO NOT MODIFY - this is managed by Git Reduce in goro and generated from build-matrix.json 2 | # 3 | --- 4 | version: 2 5 | jobs: 6 | generate-and-push-docs: 7 | docker: 8 | - image: cimg/ruby:3.0.3 9 | auth: 10 | username: "$DOCKERHUB_USERNAME" 11 | password: "$DOCKERHUB_PASSWORD" 12 | steps: 13 | - checkout 14 | - run: bundle config stitchfix01.jfrog.io $ARTIFACTORY_USER:$ARTIFACTORY_TOKEN 15 | - run: bundle install 16 | - run: 17 | name: Generate documentation 18 | command: ' if [[ $(bundle exec rake -T docs:generate:custom) ]]; then echo 19 | "Generating docs using rake task docs:generate:custom" ; bundle exec rake 20 | docs:generate:custom ; elif [[ $(bundle exec rake -T docs:generate) ]]; 21 | then echo "Generating docs using rake task docs:generate" ; bundle exec 22 | rake docs:generate ; else echo "Skipping doc generation" ; exit 0 ; fi ' 23 | - run: 24 | name: Push documentation to Unwritten 25 | command: if [[ $(bundle exec rake -T docs:push) ]]; then bundle exec rake 26 | docs:push; fi 27 | release: 28 | docker: 29 | - image: cimg/ruby:3.0.3 30 | auth: 31 | username: "$DOCKERHUB_USERNAME" 32 | password: "$DOCKERHUB_PASSWORD" 33 | steps: 34 | - checkout 35 | - run: bundle config stitchfix01.jfrog.io $ARTIFACTORY_USER:$ARTIFACTORY_TOKEN 36 | - run: bundle install 37 | - run: 38 | name: Artifactory login 39 | command: mkdir -p ~/.gem && curl -u$ARTIFACTORY_USER:$ARTIFACTORY_TOKEN https://stitchfix01.jfrog.io/stitchfix01/api/gems/eng-gems/api/v1/api_key.yaml 40 | > ~/.gem/credentials && chmod 0600 ~/.gem/credentials 41 | - run: 42 | name: Build/release gem to artifactory 43 | command: bundle exec rake push_artifactory 44 | ruby-3.0.3-rails-7.0: 45 | docker: 46 | - image: cimg/ruby:3.0.3 47 | auth: 48 | username: "$DOCKERHUB_USERNAME" 49 | password: "$DOCKERHUB_PASSWORD" 50 | environment: 51 | BUNDLE_GEMFILE: Gemfile.rails-7.0 52 | - image: redis:2.8.12 53 | auth: 54 | username: "$DOCKERHUB_USERNAME" 55 | password: "$DOCKERHUB_PASSWORD" 56 | - image: rabbitmq:3.5.6 57 | auth: 58 | username: "$DOCKERHUB_USERNAME" 59 | password: "$DOCKERHUB_PASSWORD" 60 | working_directory: "~/pwwka" 61 | steps: 62 | - checkout 63 | - run: 64 | name: Check for Gemfile.lock presence 65 | command: ' if (test -f Gemfile.lock) then echo "Dont commit Gemfile.lock (see 66 | https://github.com/stitchfix/eng-wiki/blob/main/architecture-decisions/0009-rubygem-dependencies-will-be-managed-more-explicitly.md)" 67 | 1>&2 ; exit 1 ; else exit 0 ; fi ' 68 | - run: bundle config stitchfix01.jfrog.io $ARTIFACTORY_USER:$ARTIFACTORY_TOKEN 69 | - run: bundle install 70 | - run: bundle exec rspec --format RspecJunitFormatter --out /tmp/test-results/rspec.xml 71 | --format=doc 72 | - run: 73 | name: Run Additional CI Steps 74 | command: if [ -e bin/additional-ci-steps ]; then bin/additional-ci-steps; 75 | fi 76 | - run: 77 | name: Notify Pager Duty 78 | command: bundle exec y-notify "#eng-messaging-ops" 79 | when: on_fail 80 | - store_test_results: 81 | path: "/tmp/test-results" 82 | ruby-2.7.5-rails-7.0: 83 | docker: 84 | - image: cimg/ruby:2.7.5 85 | auth: 86 | username: "$DOCKERHUB_USERNAME" 87 | password: "$DOCKERHUB_PASSWORD" 88 | environment: 89 | BUNDLE_GEMFILE: Gemfile.rails-7.0 90 | - image: redis:2.8.12 91 | auth: 92 | username: "$DOCKERHUB_USERNAME" 93 | password: "$DOCKERHUB_PASSWORD" 94 | - image: rabbitmq:3.5.6 95 | auth: 96 | username: "$DOCKERHUB_USERNAME" 97 | password: "$DOCKERHUB_PASSWORD" 98 | working_directory: "~/pwwka" 99 | steps: 100 | - checkout 101 | - run: 102 | name: Check for Gemfile.lock presence 103 | command: ' if (test -f Gemfile.lock) then echo "Dont commit Gemfile.lock (see 104 | https://github.com/stitchfix/eng-wiki/blob/main/architecture-decisions/0009-rubygem-dependencies-will-be-managed-more-explicitly.md)" 105 | 1>&2 ; exit 1 ; else exit 0 ; fi ' 106 | - run: bundle config stitchfix01.jfrog.io $ARTIFACTORY_USER:$ARTIFACTORY_TOKEN 107 | - run: bundle install 108 | - run: bundle exec rspec --format RspecJunitFormatter --out /tmp/test-results/rspec.xml 109 | --format=doc 110 | - run: 111 | name: Run Additional CI Steps 112 | command: if [ -e bin/additional-ci-steps ]; then bin/additional-ci-steps; 113 | fi 114 | - run: 115 | name: Notify Pager Duty 116 | command: bundle exec y-notify "#eng-messaging-ops" 117 | when: on_fail 118 | - store_test_results: 119 | path: "/tmp/test-results" 120 | ruby-3.0.3-rails-6.1: 121 | docker: 122 | - image: cimg/ruby:3.0.3 123 | auth: 124 | username: "$DOCKERHUB_USERNAME" 125 | password: "$DOCKERHUB_PASSWORD" 126 | environment: 127 | BUNDLE_GEMFILE: Gemfile.rails-6.1 128 | - image: redis:2.8.12 129 | auth: 130 | username: "$DOCKERHUB_USERNAME" 131 | password: "$DOCKERHUB_PASSWORD" 132 | - image: rabbitmq:3.5.6 133 | auth: 134 | username: "$DOCKERHUB_USERNAME" 135 | password: "$DOCKERHUB_PASSWORD" 136 | working_directory: "~/pwwka" 137 | steps: 138 | - checkout 139 | - run: 140 | name: Check for Gemfile.lock presence 141 | command: ' if (test -f Gemfile.lock) then echo "Dont commit Gemfile.lock (see 142 | https://github.com/stitchfix/eng-wiki/blob/main/architecture-decisions/0009-rubygem-dependencies-will-be-managed-more-explicitly.md)" 143 | 1>&2 ; exit 1 ; else exit 0 ; fi ' 144 | - run: bundle config stitchfix01.jfrog.io $ARTIFACTORY_USER:$ARTIFACTORY_TOKEN 145 | - run: bundle install 146 | - run: bundle exec rspec --format RspecJunitFormatter --out /tmp/test-results/rspec.xml 147 | --format=doc 148 | - run: 149 | name: Run Additional CI Steps 150 | command: if [ -e bin/additional-ci-steps ]; then bin/additional-ci-steps; 151 | fi 152 | - run: 153 | name: Notify Pager Duty 154 | command: bundle exec y-notify "#eng-messaging-ops" 155 | when: on_fail 156 | - store_test_results: 157 | path: "/tmp/test-results" 158 | ruby-2.7.5-rails-6.1: 159 | docker: 160 | - image: cimg/ruby:2.7.5 161 | auth: 162 | username: "$DOCKERHUB_USERNAME" 163 | password: "$DOCKERHUB_PASSWORD" 164 | environment: 165 | BUNDLE_GEMFILE: Gemfile.rails-6.1 166 | - image: redis:2.8.12 167 | auth: 168 | username: "$DOCKERHUB_USERNAME" 169 | password: "$DOCKERHUB_PASSWORD" 170 | - image: rabbitmq:3.5.6 171 | auth: 172 | username: "$DOCKERHUB_USERNAME" 173 | password: "$DOCKERHUB_PASSWORD" 174 | working_directory: "~/pwwka" 175 | steps: 176 | - checkout 177 | - run: 178 | name: Check for Gemfile.lock presence 179 | command: ' if (test -f Gemfile.lock) then echo "Dont commit Gemfile.lock (see 180 | https://github.com/stitchfix/eng-wiki/blob/main/architecture-decisions/0009-rubygem-dependencies-will-be-managed-more-explicitly.md)" 181 | 1>&2 ; exit 1 ; else exit 0 ; fi ' 182 | - run: bundle config stitchfix01.jfrog.io $ARTIFACTORY_USER:$ARTIFACTORY_TOKEN 183 | - run: bundle install 184 | - run: bundle exec rspec --format RspecJunitFormatter --out /tmp/test-results/rspec.xml 185 | --format=doc 186 | - run: 187 | name: Run Additional CI Steps 188 | command: if [ -e bin/additional-ci-steps ]; then bin/additional-ci-steps; 189 | fi 190 | - run: 191 | name: Notify Pager Duty 192 | command: bundle exec y-notify "#eng-messaging-ops" 193 | when: on_fail 194 | - store_test_results: 195 | path: "/tmp/test-results" 196 | workflows: 197 | version: 2 198 | on-commit: 199 | jobs: 200 | - release: 201 | context: org-global 202 | requires: 203 | - ruby-3.0.3-rails-7.0 204 | - ruby-2.7.5-rails-7.0 205 | - ruby-3.0.3-rails-6.1 206 | - ruby-2.7.5-rails-6.1 207 | filters: 208 | tags: 209 | only: /^[0-9]+\.[0-9]+\.[0-9]+(\.?(RC|rc)[-\.]?\w*)?$/ 210 | branches: 211 | ignore: /.*/ 212 | - generate-and-push-docs: 213 | context: org-global 214 | requires: 215 | - release 216 | filters: 217 | tags: 218 | only: /^[0-9]+\.[0-9]+\.[0-9]+(\.?(RC|rc)[-\.]?\w*)?$/ 219 | branches: 220 | ignore: /.*/ 221 | - ruby-3.0.3-rails-7.0: 222 | context: org-global 223 | filters: 224 | tags: 225 | only: &1 /.*/ 226 | - ruby-2.7.5-rails-7.0: 227 | context: org-global 228 | filters: 229 | tags: 230 | only: *1 231 | - ruby-3.0.3-rails-6.1: 232 | context: org-global 233 | filters: 234 | tags: 235 | only: *1 236 | - ruby-2.7.5-rails-6.1: 237 | context: org-global 238 | filters: 239 | tags: 240 | only: *1 241 | scheduled: 242 | triggers: 243 | - schedule: 244 | cron: 17 19 * * 1,2,3,4,5 245 | filters: 246 | branches: 247 | only: 248 | - main 249 | jobs: 250 | - ruby-3.0.3-rails-7.0: 251 | context: org-global 252 | - ruby-2.7.5-rails-7.0: 253 | context: org-global 254 | - ruby-3.0.3-rails-6.1: 255 | context: org-global 256 | - ruby-2.7.5-rails-6.1: 257 | context: org-global 258 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | pkg 2 | spec/reports 3 | .vimrc 4 | *.sw? 5 | .idea/ 6 | config/database.yml 7 | db 8 | .tddium* 9 | .DS_Store 10 | .jhw-cache 11 | *.orig 12 | .rspec 13 | .bundle 14 | coverage 15 | Session.vim 16 | Gemfile.lock 17 | -------------------------------------------------------------------------------- /.ruby-gemset: -------------------------------------------------------------------------------- 1 | message_handler 2 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | ruby-3.0.3 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | sudo: true 3 | rvm: 4 | - 2.3.3 5 | - 2.4.2 6 | services: 7 | - docker 8 | before_install: 9 | - docker-compose up -d 10 | script: 11 | - bundle exec rspec 12 | 13 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @stitchfix/eng-messaging 2 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | We are committed to keeping our community open and inclusive. 4 | 5 | **Our Code of Conduct can be found here**: 6 | http://opensource.stitchfix.com/code-of-conduct.html 7 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | Thanks for using and improving *pwwka*! If you'd like to help out, check out [the project's issues list](https://github.com/stitchfix/pwwka/issues) for ideas on what could be improved. 3 | 4 | We're actively using Pwwka in production here at [Stitch Fix](http://technology.stitchfix.com/) and look forward to seeing Pwwka grow and improve with your help. Contributions are warmly welcomed. 5 | 6 | If there's an idea you'd like to propose, or a design change, feel free to file a new issue or send a pull request: 7 | 8 | 1. [Fork][fork] the repo. 9 | 1. [Create a topic branch.][branch] 10 | 1. Write tests. 11 | 1. Implement your feature or fix bug. 12 | 1. Add, commit, and push your changes. 13 | 1. [Submit a pull request.][pr] 14 | 15 | [fork]: https://help.github.com/articles/fork-a-repo/ 16 | [branch]: https://help.github.com/articles/creating-and-deleting-branches-within-your-repository/ 17 | [pr]: https://help.github.com/articles/using-pull-requests/ 18 | 19 | ## General Guidelines 20 | 21 | * When in doubt, test it. If you can't test it, re-think what you are doing. 22 | * Code formatting and internal application architecture should be consistent. 23 | 24 | ## Testing 25 | 26 | The tests assume that: 27 | 28 | * Rabbit is running on port 10001 29 | * Redis is running on port 10003 30 | 31 | You can achieve this by using Docker and running `docker-compose up` in the root of this directory. If you don't want to use Docker, 32 | that's fine. You'll need to set `PWWKA_RESQUE_REDIS_PORT` and `PWWKA_RABBIT_PORT` in your environment to the ports where those services 33 | are running. 34 | 35 | Tests in `spec/integration` are end-to-end tests that use Rabbita and attempt to assert behavior from the point of view of the application 36 | owner. If you write tests here, depend on as few of Pwwka's internals as possible, and *no mocking of anything*. 37 | 38 | Tests in `spec/unit` are more traditional unit tests and *should not require Rabbit or Redis* to be running. 39 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://www.rubygems.org' 2 | 3 | gemspec 4 | -------------------------------------------------------------------------------- /Gemfile.rails-6.1: -------------------------------------------------------------------------------- 1 | # DO NOT MODIFY - this is managed by Git Reduce in goro 2 | # 3 | source 'https://stitchfix01.jfrog.io/stitchfix01/api/gems/eng-gems/' 4 | 5 | gemspec 6 | 7 | gem 'activesupport', '~> 6.1.0' 8 | -------------------------------------------------------------------------------- /Gemfile.rails-7.0: -------------------------------------------------------------------------------- 1 | # DO NOT MODIFY - this is managed by Git Reduce in goro 2 | # 3 | source 'https://stitchfix01.jfrog.io/stitchfix01/api/gems/eng-gems/' 4 | 5 | gemspec 6 | 7 | gem 'activesupport', '~> 7.0.0' 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License (MIT) 2 | 3 | Copyright (c) 2014 Stitch Fix 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Pwwka 2 | 3 | 4 | Pronounced "Poo-ka" |ˈpo͞okə| 5 | 6 | ![Pwwka Legit](http://res.cloudinary.com/stitch-fix/image/upload/c_scale,h_300/v1413580920/pwwka_yuw7hl.png) 7 | 8 | --- 9 | [![Build Status](https://travis-ci.org/stitchfix/pwwka.svg?branch=add_travis_yml)](https://travis-ci.org/stitchfix/pwwka) 10 | 11 | Provides the means to both send and handle messages on an exchange of a RabbitMQ server. In a sense, this provides the RabbitMQ equivalent 12 | of `Resque.enqueue` and `SomeResqueJob.perform`. 13 | 14 | ## Set Up 15 | 16 | In your `Gemfile`: 17 | 18 | ```ruby 19 | gem 'pwwka' 20 | ``` 21 | 22 | or `gem install pwwka` if you aren't using a `Gemfile`. 23 | 24 | To run applications locally, you will need Rabbit installed. The [installation guide](https://www.rabbitmq.com/download.html) is a great 25 | place to start. This repo includes a `docker-compose.yml` file which will run Rabbit inside a Docker container. It's used by the tests, 26 | but you can use that, too. 27 | 28 | ### Configuration 29 | 30 | Somewhere in your app, run the following code (in Rails, this would be `config/initializers/pwwka.rb`): 31 | 32 | ```ruby 33 | require 'pwwka' 34 | Pwwka.configure do |config| 35 | config.rabbit_mq_host = ENV['RABBITMQ_URL'] 36 | config.topic_exchange_name = "mycompany-topics-#{Rails.env}" 37 | config.delayed_exchange_name = "mycompany-topics-#{Rails.env}" 38 | config.options = {allow_delayed: true} 39 | config.requeue_on_error = true 40 | config.default_prefetch = 10 41 | config.process_name = "my-process-name" 42 | end 43 | ``` 44 | 45 | Note that the absence of `RABBITMQ_URL` in your environment will cause the underlying RabbitMQ library to use the defaults. If you aren't 46 | using the defaults, set that environment variable to something like this: 47 | 48 | ``` 49 | amqp://«user»:«password»@«host»:«port»/«vhost» 50 | ``` 51 | 52 | The defaults should be `amqp://guest:guest@localhost:5672/`, i.e.: 53 | 54 | * user: guest 55 | * password: guest 56 | * host: localhost 57 | * port: 5672 58 | * vhost: `/` 59 | 60 | ## Setting it up 61 | 62 | ### Install RabbitMQ locally 63 | 64 | ``` 65 | brew install rabbitmq 66 | ``` 67 | 68 | And follow the instructions. 69 | 70 | ### Adding it to your app 71 | 72 | Add to your `Gemfile`: 73 | 74 | ```ruby 75 | gem 'pwwka' 76 | ``` 77 | 78 | ## Using Pwwka 79 | 80 | Pwwka provides the ability to send a message into Rabbit as well a the ability to receive/handle a message. Your app can do both of these 81 | things if it needs to. 82 | 83 | 84 | ### Sending a message 85 | 86 | You can send any kind of message using `Pwwka::Transmitter.send_message!`: 87 | 88 | ```ruby 89 | payload = {client_id: '13452564'} 90 | routing_key = 'sf.clients.client.created' 91 | Pwwka::Transmitter.send_message!(payload, routing_key) 92 | ``` 93 | 94 | The payload should be a simple hash containing primitives. Don't send objects because the payload will be converted to JSON for sending. 95 | 96 | #### AMQP Attributes 97 | 98 | By default, pwwka will set the following [AMQP Attributes](http://stackoverflow.com/questions/18403623/rabbitmq-amqp-basicproperties-builder-values/18447385#18447385): 99 | 100 | * `message_id` - a GUID 101 | * `timestamp` - The time the message is sent 102 | * `app_id` - the name of your Rails app or, if you aren't using rails, the value of `app_id` given to the configuration 103 | * `content_type` - `application/json; version=1` 104 | 105 | You may optionally set the following when sending a message to set these additional attributes: 106 | 107 | * `message_id` - to override the GUID. Generally don't do this. 108 | * `type` - a String to define the data type you are sending. Useful for languages with static types to know how to 109 | deserialize. You should ensure that the combo of `app_id` and `type` are unique to your entire ecosystem or consumers won't 110 | know what they are receiving. 111 | * `headers` - a hash of arbitrary headers. 112 | 113 | A fuller example: 114 | 115 | ```ruby 116 | Pwwka::Transmitter.send_message!( 117 | { "customer_id" => 12345, "active" => true }, 118 | "customers.customer.created", 119 | type: "Customer", 120 | headers: { 121 | "RAILS_VERSION" => "5.1.1" 122 | } 123 | ) 124 | ``` 125 | 126 | #### Error Handling 127 | 128 | `Pwwka::Transmitter.send_message!` accepts several strategies for handling errors, passed in using the `on_error` parameter: 129 | 130 | * `:raise` - Log the error and raise the exception received from Bunny. (default strategy) 131 | * `:ignore` - Log the error and return false. 132 | * `:retry_async` - Log the error and return false. Also, enqueue a job with the configured background job processor (`:resque` or `:sidekiq`). **Note, this doesn't guarantee the message will actually be sent— it just guarantees an attempt is made to queue a background job [which could fail]**. The background job processor will default to Resque, but can be configured to Sidekiq: 133 | 134 | ``` 135 | Pwwka.configure do |config| 136 | config.background_job_processor = :sidekiq 137 | end 138 | ``` 139 | 140 | Example usage: 141 | 142 | ```ruby 143 | payload = {client_id: '13452564'} 144 | routing_key = 'sf.clients.client.created' 145 | Pwwka::Transmitter.send_message!(payload, routing_key, on_error: :ignore) 146 | ``` 147 | 148 | 149 | #### Delayed Messages 150 | 151 | You might want to delay sending a message (for example, if you have just created a database 152 | record and a race condition keeps catching you out). In that case you can use delayed message 153 | options: 154 | 155 | ```ruby 156 | payload = {client_id: '13452564'} 157 | routing_key = 'sf.clients.client.created' 158 | Pwwka::Transmitter.send_message!(payload, routing_key, delayed: true, delay_by: 3000) 159 | ``` 160 | 161 | `delay_by` is an integer of milliseconds to delay the message. The default (if no value is set) is 5000 (5 seconds). 162 | 163 | These extra arguments work for all message sending methods - the safe ones, the handling, and the `message_queuer` methods (see below). 164 | 165 | 166 | #### Sending message asynchronously 167 | To enqueue a message in a background job, use `Pwwka::Transmitter.send_message_async`: 168 | ```ruby 169 | Pwwka::Transmitter.send_message_async(payload, routing_key, delay_by_ms: 5000) # default delay is 0 170 | ``` 171 | 172 | The job will be enqueued using the configured background job processor. This will default to Resque, but can be configured to use Sidekiq: 173 | ``` 174 | Pwwka.configure do |config| 175 | config.background_job_processor = :sidekiq 176 | end 177 | ``` 178 | 179 | Regardless of which processor you use, the name of the queue created is `pwwka_send_message_async`. You will need to start a worker process to work the queue. For a `Procfile` setup, with Resque as the processor, that could look something like this: 180 | 181 | ```ruby 182 | pwwka_send_message_async_worker: rake resque:work QUEUE=pwwka_send_message_async 183 | ``` 184 | 185 | 186 | You can also configure Pwwka to use your own custom job using the `async_job_klass` configuration option. An example might be: 187 | ``` 188 | Pwwka.configure do |config| 189 | config.async_job_klass = YourApp::PwwkaAsyncJob 190 | end 191 | ``` 192 | 193 | If you are using Resque and `Resque::Plugins::ExponentialBackoff` is available, the job will use it. (Important: Your load/require order is important if you want exponential backoff with the built-in job due to [its error handling](https://github.com/stitchfix/pwwka/blob/713c6003fa6cf52cb4713c02b39fe7ee07ebe2e9/lib/pwwka/send_message_async_job.rb#L8)). Customize the backoff intervals using the configuration `send_message_resque_backoff_strategy`. The default backoff will retry quickly in case of an intermittent glitch, and then every ten minutes for half an hour 194 | 195 | 196 | 197 | #### Message Queuer 198 | 199 | You can queue up messages and send them in a batch. This is most useful when multiple messages 200 | need to sent from within a transaction block. 201 | 202 | For example: 203 | 204 | ```ruby 205 | # instantiate a message_queuer object 206 | message_queuer = MessageQueuerService.new 207 | ActiveRecord::Base.transaction do 208 | # do a thing, then queue message 209 | message_queuer.queue_message(payload: {this: 'that'}, routing_key: 'go.to.there') 210 | 211 | # do another thing, then queue a delayed message 212 | message_queuer.queue_message(payload: {the: 'other'}, routing_key: 'go.somewhere.else', delayed: true, delay_by: 3000) 213 | end 214 | # send the queued messages if we make it out of the transaction alive 215 | message_queuer.send_messages_safely 216 | ``` 217 | 218 | ### Receiving messages 219 | 220 | The message-handler comes with a rake task you can use (e.g. in your Procfile) to start up your message handler worker: 221 | 222 | ```ruby 223 | message_handler: rake message_handler:receive HANDLER_KLASS=ClientIndexMessageHandler QUEUE_NAME=adminapp_style_index ROUTING_KEY='client.#.updated' 224 | ``` 225 | 226 | It requires some environment variables to work: 227 | 228 | * `HANDLER_KLASS` (required) refers to the class you have to write in your app (equivalent to a `job` in Resque) 229 | * `QUEUE_NAME` (required) we must use named queues - see below 230 | * `ROUTING_KEY` (optional) comma separated list of routing keys (e.g. `foo.bar.*,foo.baz.*`). defaults to `#.#` (all messages) 231 | * `PREFETCH` (optional) sets a [prefetch value](http://rubybunny.info/articles/queues.html#qos__prefetching_messages) for the subscriber 232 | 233 | You'll also need to bring the Rake task into your app. For Rails, you'll need to edit the top-level `Rakefile`: 234 | 235 | ```ruby 236 | require 'pwwka/tasks' 237 | ``` 238 | 239 | #### Queues - what messages will your queue receive 240 | 241 | It depends on your `routing_key`. If you set your routing key to `#.#` (the default) it will receive all the messages. The `#` is a wildcard so if you set it to `client.#` it will receive any message with `client.` at the beginning. The exchange registers the queue's name and routing key so it knows what messages the queue is supposed to receive. A named queue will receive each message it expects to get once and only once. 242 | 243 | The available wildcards are as follows (and are not intuitive): 244 | * `*` (star) can substitute for **exactly one word**. 245 | * `#` (hash) can substitute for zero or more words. 246 | 247 | __A note on re-queuing:__ At the moment messages that raise an error on receipt are marked 'not acknowledged, don't resend', and the failure message is logged. You can configure a single retry by setting the configuration option `requeue_on_error`. Note that all unacknowledged messages will be resent when the worker is restarted. 248 | 249 | __Spinning up some more handlers to handle the load:__ Since each named queue will receive each message only once you can spin up multiple process using the *same named queue* and they will share the messages between them. If you spin up three processes each will receive roughly one third of the messages, but each message will still only be received once. 250 | 251 | #### Handlers - The class that handles received messages 252 | 253 | Handlers are simple classes that must respond to `self.handle!`. The receiver will send the handler three arguments: 254 | 255 | * `delivery_info` - [a bunch of stuff](http://rubybunny.info/articles/queues.html#accessing_message_delivery_information) 256 | * `properties` - [a bunch of other stuff](http://rubybunny.info/articles/queues.html#accessing_message_properties_metadata) 257 | * `payload` - the hash sent by the transmitter 258 | 259 | Here is an example: 260 | 261 | ```ruby 262 | class ClientIndexMessageHandler 263 | 264 | def self.handle!(delivery_info, properties, payload) 265 | handler.do_a_thing(payload) 266 | end 267 | 268 | private 269 | 270 | def self.do_a_thing(payload) 271 | ### 272 | # some stuff that is being done 273 | ### 274 | end 275 | 276 | end 277 | ``` 278 | 279 | #### Payload Parsing 280 | 281 | By default, payloads are assumed to be JSON and are parsed before being sent to your `handle!` method (meaning: that method is 282 | given a `HashWithIndifferentAccess` of your payload). 283 | 284 | If you don't want this, for example if you are using XML or some other format, you can turn this feature off in your 285 | initializers: 286 | 287 | ```ruby 288 | # config/initialisers/pwwka.rb 289 | require 'pwwka' 290 | 291 | Pwwka.configure do |config| 292 | config.receive_raw_payload = true 293 | # any other settings you might have 294 | end 295 | ``` 296 | 297 | In this case, your handler gets whatever Bunny returns, so you are on your own. 298 | 299 | #### Errors From Your Handler 300 | 301 | By default, handlers will log and ignore garbled payloads (basically payloads that fail to be parsed as JSON). All other errors 302 | will crash the handler, under the assumption that it will restart. This is good, because it allows you to recover from most intermittent things. Just be aware of this when configuring your handler so that it gets restarted after a crash. 303 | 304 | What happens to the message you received during the error depends: 305 | 306 | * If the error is not a `StandardError` or a subclass, the message will not be ack'ed and will be waiting on the queue for you when you next fetch a message 307 | * If the errors *is* a `StandardError` or a subclass, the message will be ack'ed and removed from the queue. 308 | - By default, the message is not re-queued and is essentially dropped on the floor. Its payload is logged, so you can recover that way. 309 | - If you set `requeue_on_error = true` in your Pwwka configuration, a message gets requeued **exactly once** on failure. If the message involved in the failure has been redelivered before, it's dropped on the floor. This behavior allows you to recover from most intermittent failures, like so: 310 | 1. You receive message for the first time. 311 | 1. Intermittent failure (e.g. network problem) happens, and an exception is raised. 312 | 1. Pwwka catches this exception and requeues the message. 313 | 1. Pwwka then crashes your handler. 314 | 1. Your handler restarts. 315 | 1. The message is in the queue, waiting for you. 316 | 1. You handle it. (*if you error here, the message is not requeued*) 317 | 318 | The reason we don't always requeue on error is that a hard failure would result in an infinite loop. The reason we don't use the dead letter exchange is that there is no way in the Rabbit console to deal with 319 | these messages. Some day Pwwka might have code to allow that. Today is not that day. 320 | 321 | **You should configure `requeue_on_error`**. It's not the default for backwards compatibility. 322 | 323 | #### Advanced Error Handling 324 | 325 | The underlying implementation of how errors are handled is via a [chain of responsibility-ish](https://en.wikipedia.org/wiki/Chain-of-responsibility_pattern) implementation. When an unhandled exception occurs, pwwka's `Receiver` 326 | defers to the configurations `error_handling_chain`, which is a list of classes that can handle errors. `requeue_on_error` and `keep_alive_on_handler_klass_exceptions` control which classes are in the chain. 327 | 328 | If you want to handle errors differently, for example crashing on some exceptions, but not others, or requeing messages on failures always (instead of just once), you can do that by subclassing `Pwwka::ErrorHandlers::BaseHandler`. 329 | It defines a method `handle_error` that is given the `Receiver` instance, queue name, payload, delivery info, and the uncaught exception. If the method returns `true`, Pwwka calls the remaining handlers. If false, it stops processing. 330 | 331 | Your subclass can be inserted into the chain in two ways. Way #1 is to override the entire chain by setting `Pwwka.configuration.error_handling_chain` to an array of handlers, including yours. Way #2 is to have your specific 332 | message handler implement `self.error_handler` to return the class to be used for just that message handler. 333 | 334 | **When you do this**, be careful to ensure you ack or nack. If you fail to do either, your messages will build up and bad things will happen. 335 | 336 | For example, suppose you want to catch an ActiveRecord error, unwrap it to see if it's a problem with the connection, and reconnect before trying again. 337 | 338 | First, implement your custom error handler: 339 | 340 | ```ruby 341 | class PostgresReconnectHandler < Pwwka::ErrorHandlers::BaseHandler 342 | def handle_error(receiver,queue_name,payload,delivery_info,exception) 343 | if exception.cause.is_a?(PG::ConnectionBad) 344 | ActiveRecord::Base.connection.reconnect! 345 | end 346 | keep_going 347 | end 348 | end 349 | ``` 350 | 351 | In your pwwka initializer: 352 | 353 | ```ruby 354 | require 'pwwka' 355 | Pwwka.configure do |config| 356 | config.rabbit_mq_host = ENV['RABBITMQ_URL'] 357 | config.topic_exchange_name = "mycompany-topics-#{Rails.env}" 358 | config.delayed_exchange_name = "mycompany-topics-#{Rails.env}" 359 | config.options = {allow_delayed: true} 360 | config.error_handling_chain = [ 361 | PostgresReconnectHandler, 362 | Pwwka::ErrorHandlers::NackAndRequeueOnce, 363 | Pwwka::ErrorHandlers::Crash 364 | ] 365 | end 366 | ``` 367 | 368 | This says: 369 | 370 | * If the error was a `PG::ConnectionBad`, reconnect 371 | * If the message has not been retried, nack it and requeue it, otherwise ignore it (`NackAndRequeueOnce`) 372 | * Crash the handler 373 | 374 | You might not want to crash the handler in the case of `PG::ConnectionBad`. And, you might want to always retry the job, even if it's been retried before so you don't lose it. 375 | 376 | In that case, your handler could work like this: 377 | 378 | 379 | ```ruby 380 | class PostgresReconnectHandler < Pwwka::ErrorHandlers::BaseHandler 381 | def handle_error(receiver,queue_name,payload,delivery_info,exception) 382 | if exception.cause.is_a?(PG::ConnectionBad) 383 | ActiveRecord::Base.connection.reconnect! 384 | log("Retrying an Error Processing Message",queue_name,payload,delivery_info,exception) 385 | receiver.nack_requeue(delivery_info.delivery_tag) 386 | abort_chain 387 | else 388 | keep_going 389 | end 390 | end 391 | end 392 | ``` 393 | 394 | Now, if we get a `PG::ConnectionBad`, we reconnect, nack with requeue and stop processing the error (`abort_chain` is an alias for `false`, and `keep_going` is an alias for `true`, but they keep you from having to remember what to return). 395 | 396 | **When making your own handlers** it's important to make sure that the message is nacked or acked.** 397 | 398 | #### Handling Messages with Resque 399 | 400 | If you use [Resque][resque], and you wish to handle messages in a resque job, you can use `Pwwka::QueueResqueJobHandler`, which is an adapter between the standard `handle!` method provided by pwwka and your Resque job. 401 | 402 | 1. First, modify your `Gemfile` or otherwise arrange to include `pwwka/queue_resque_job_handler`: 403 | 404 | ```ruby 405 | gem 'pwwka', require: [ 'pwwka', 'pwwka/queue_resque_job_handler' ] 406 | ``` 407 | 408 | or, in `config/initializers/pwwka.rb`: 409 | 410 | ```ruby 411 | require 'pwwka/queue_resque_job_handler' 412 | ``` 413 | 414 | 2. Now, configure your handler. For a `Procfile` setup: 415 | 416 | ``` 417 | my_handler: rake message_handler:receive HANDLER_KLASS=Pwwka::QueueResqueJobHandler JOB_KLASS=MyResqueJob QUEUE_NAME=my_queue ROUTING_KEY="my.key.completed" 418 | ``` 419 | 420 | Note the use of the environment variable `JOB_KLASS`. This tells `QueueResqueJobHandler` which class to queue. 421 | 3. Now, write your job. 422 | 423 | ```ruby 424 | class MyResqueJob 425 | @queue = :my_resque_queue 426 | 427 | def self.perform(payload, # the payload 428 | routing_key, # routing key as a string 429 | message_properties) # properties as a hash with _String_ keys 430 | user = User.find(payload.fetch("user_id")) # or whatever 431 | user.frobnosticate! 432 | end 433 | end 434 | ``` 435 | 436 | Note that you must provide `@queue` in your job. `QueueResqueJobHandler` doesn't support setting a custom queue at enqueue-time (PRs welcome :). 437 | 438 | Note that if you were using this library before version 0.12.0, your job would only be given the payload. If you change your job to accept exactly three arguments, you will be given the payload, routing key, and message properties. If any of those arguments are optional, you will need to set `PWWKA_QUEUE_EXTENDED_INFO` to `"true"` to force pwwka to pass those along. Without it, your job only gets the payload to avoid breaking legacy consumers. 439 | 440 | 3. Profit! 441 | 442 | [resque]: https://github.com/resque/resque/tree/1-x-stable 443 | 444 | ### Testing 445 | 446 | This gem has test coverage of interacting with RabbitMQ, so for unit tests, your best 447 | strategy is to simply mock calls to `Pwwka::Transmitter`. 448 | 449 | For integration tests, however, you can examine the actual message bus by setting up 450 | the provided `Pwwka::TestHandler` like so: 451 | 452 | ```ruby 453 | require 'pwwka/test_handler' 454 | 455 | describe "my integration test" do 456 | 457 | before(:all) do 458 | @test_handler = Pwwka::TestHandler.new 459 | @test_handler.test_setup 460 | end 461 | 462 | after(:all) do 463 | # this clears out any messages, so you have a clean test environment next time 464 | @test_handler.test_teardown 465 | end 466 | 467 | it "uses the message bus" do 468 | post "/items", item: { size: "L" } 469 | 470 | message = @test_handler.pop_message 471 | 472 | expect(message.delivery_info.routing_key).to eq("my-company.items.created") 473 | expect(message.payload).to eq({ item: { id: 42, size: "L" } }) 474 | end 475 | 476 | it "can splat the values as well" do 477 | post "/items", item: { size: "L" } 478 | 479 | delivery_info, payload = @test_handler.pop_message 480 | 481 | expect(delivery_info.routing_key).to eq("my-company.items.created") 482 | expect(payload).to eq({ item: { id: 42, size: "L" } }) 483 | end 484 | end 485 | ``` 486 | 487 | [See CONTRIBUTING.md for details on testing this gem](CONTRIBUTING.md#testing) 488 | 489 | 490 | ## Better Know a Message Bus 491 | 492 | If you aren't familiar with Rabbit or Message Busses, the idea is that messages can be sent “into the ether” with no particular 493 | destination. Subscribers can listen for those messages and choose to respond. 494 | 495 | For example, suppose a customer purchases an order. The app serving our public website sends a message that this has happened. Another 496 | app that sends emails will hear that message, and use it to trigger a receipt email to the customer. A yet other app that does financial 497 | reporting might hear this same message and record the sale to the company's ledger. The app serving our public website doesn't know about 498 | any of these things. 499 | 500 | ### How Pwwka Uses Rabbit 501 | 502 | All transmitters and receivers share the same exchange. This means that all receivers can read all messages that any transmitter sends. To ensure that all messages are received by eveyone who wants them the Pwwka configures everything as follows: 503 | 504 | * The exchange is named and durable. If the service goes down and restarts the named exchange will return with the same settings so everyone can reconnect. 505 | * The receiver queues are all named and durable. If the service goes down and restarts the named queue will return with the same settings so everyone can reconnect, and with any unacknowledged messages waiting to be received. 506 | * All messages are sent as persistent and require acknowledgement. They will stick around and wait to be received and acknowledged by every queue that wants them, regardless of service interruptions. 507 | 508 | 509 | ### Monitoring 510 | 511 | RabbitMQ has a good API that should make it easy to set up some simple monitoring. In the meantime there is logging and manual monitoring. 512 | 513 | #### Logging 514 | 515 | The receiver logs details of any exception raised in message handling: 516 | 517 | ```ruby 518 | error "Error Processing Message on #{queue_name} -> #{payload}, #{delivery_info.routing_key}: #{e}" 519 | ``` 520 | 521 | The transmitter will likewise log an error if you use the `_safely` methods: 522 | 523 | ```ruby 524 | error "Error Transmitting Message on #{routing_key} -> #{payload}: #{e}" 525 | ``` 526 | 527 | If your payloads are large, you may not want to log them 2-3 times per message. In that case, you can adjust `payload_logging` in the configuration: 528 | 529 | ```ruby 530 | Pwwka.configuration.payload_logging = :info # The default - payloads appear at INFO and above log levels 531 | Pwwka.configuration.payload_logging = :error # Only log payloads for ERROR or FATAL messages 532 | Pwwka.configuration.payload_logging = :fatal # Only log payloads for FATAL messages 533 | ``` 534 | 535 | You can also hook into logging by passing a hash containing keys of strings to match and corresponding `Proc` objects for the logger to execute instead of logging a message. The `Proc` will be called with the original message string that was to be logged and the params specific for that log event. So, if for instance, you wanted to emit a count metric to your monitoring system instead of logging each processed message you could set the configuration: 536 | 537 | ```ruby 538 | Pwwka.configuration.log_hooks = { 'Processed Message on' => ->(message, params){ $stats.count('message_processed') } } 539 | ``` 540 | 541 | #### Manual monitoring 542 | 543 | RabbitMQ has a web interface for checking out the health of connections, channels, exchanges and queues. Your RabbitMQ provider should 544 | provide a link. If you are running Rabbit locally, the management interface is on port 15672 by default (or port 10002 if using the included `docker-compose.yml`). The user is "guest" and the password is "guest". 545 | 546 | ![RabbitMQ Management 1](docs/images/RabbitMQ_Management.png) 547 | ![RabbitMQ Management 2](docs/images/RabbitMQ_Management-2.png) 548 | ![RabbitMQ Management 3](docs/images/RabbitMQ_Management-3.png) 549 | 550 | ## Contributing 551 | 552 | We're actively using Pwwka in production here at [Stitch Fix](http://technology.stitchfix.com/) and look forward to seeing Pwwka grow and improve with your help. Contributions are warmly welcomed. 553 | 554 | [See CONTRIBUTING.md for details](CONTRIBUTING.md) 555 | 556 | ## Licence 557 | 558 | Pwwka is released under the [MIT License](http://www.opensource.org/licenses/MIT). 559 | 560 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rubygems/package_task' 2 | require 'rspec/core/rake_task' 3 | require 'bundler' 4 | 5 | $: << File.join(File.dirname(__FILE__),'lib') 6 | 7 | include Rake::DSL 8 | 9 | gemspec = eval(File.read('pwwka.gemspec')) 10 | Gem::PackageTask.new(gemspec) {} 11 | RSpec::Core::RakeTask.new(:spec) 12 | Bundler::GemHelper.install_tasks 13 | 14 | task default: :spec 15 | -------------------------------------------------------------------------------- /build-matrix.json: -------------------------------------------------------------------------------- 1 | { 2 | "build_matrix": { 3 | "additional_docker_images": [ 4 | "redis:2.8.12", 5 | "rabbitmq:3.5.6" 6 | ] 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | services: 3 | rabbit: 4 | image: rabbitmq:3.5.6-management 5 | ports: 6 | - "10001:5672" 7 | - "10002:15672" 8 | resque: 9 | image: redis:2.8.12 10 | ports: 11 | - "10003:6379" 12 | -------------------------------------------------------------------------------- /docs/images/RabbitMQ_Management-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stitchfix/pwwka/0d38dabe713a98eaf94c0b87e200d790cf4e3e61/docs/images/RabbitMQ_Management-2.png -------------------------------------------------------------------------------- /docs/images/RabbitMQ_Management-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stitchfix/pwwka/0d38dabe713a98eaf94c0b87e200d790cf4e3e61/docs/images/RabbitMQ_Management-3.png -------------------------------------------------------------------------------- /docs/images/RabbitMQ_Management.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stitchfix/pwwka/0d38dabe713a98eaf94c0b87e200d790cf4e3e61/docs/images/RabbitMQ_Management.png -------------------------------------------------------------------------------- /lib/pwwka.rb: -------------------------------------------------------------------------------- 1 | module Pwwka 2 | 3 | class << self 4 | def configure 5 | yield(configuration) 6 | end 7 | 8 | def configuration 9 | @configuration ||= Configuration.new 10 | end 11 | 12 | def environment 13 | ENV['RAILS_ENV'] || ENV['RACK_ENV'] || 'development' 14 | end 15 | end 16 | 17 | end 18 | 19 | require 'json' 20 | require 'active_support/inflector' 21 | require 'active_support/core_ext/module' 22 | require 'active_support/hash_with_indifferent_access' 23 | 24 | require 'pwwka/version' 25 | require 'pwwka/logging' 26 | require 'pwwka/channel_connector' 27 | require 'pwwka/handling' 28 | require 'pwwka/receiver' 29 | require 'pwwka/transmitter' 30 | require 'pwwka/message_queuer' 31 | require 'pwwka/error_handlers' 32 | require 'pwwka/configuration' 33 | require 'pwwka/send_message_async_job' 34 | require 'pwwka/send_message_async_sidekiq_job' 35 | -------------------------------------------------------------------------------- /lib/pwwka/channel_connector.rb: -------------------------------------------------------------------------------- 1 | module Pwwka 2 | class ChannelConnector 3 | extend Pwwka::Logging 4 | include Pwwka::Logging 5 | 6 | attr_reader :connection 7 | attr_reader :configuration 8 | attr_reader :channel 9 | 10 | # The channel_connector starts the connection to the message_bus 11 | # so it should only be instantiated by a method that has a strategy 12 | # for closing the connection 13 | def initialize(prefetch: nil, connection_name: nil) 14 | @configuration = Pwwka.configuration 15 | connection_options = {automatically_recover: false}.merge(configuration.options) 16 | connection_options = {client_properties: {connection_name: connection_name}}.merge(connection_options) if connection_name 17 | 18 | begin 19 | @connection = Bunny.new(configuration.rabbit_mq_host, connection_options) 20 | @connection.start 21 | rescue => e 22 | logf "ERROR Connecting to RabbitMQ: #{e}", at: :error 23 | 24 | @connection.close if @connection 25 | raise e 26 | end 27 | 28 | begin 29 | @channel = @connection.create_channel 30 | @channel.on_error do |ch, method| 31 | logf "ERROR On RabbitMQ channel: #{method.inspect}" 32 | end 33 | rescue => e 34 | logf "ERROR Opening RabbitMQ channel: #{e}", at: :error 35 | @connection.close if @connection 36 | raise e 37 | end 38 | 39 | if prefetch 40 | @channel.prefetch(prefetch.to_i) 41 | end 42 | end 43 | 44 | def topic_exchange 45 | @topic_exchange ||= channel.topic(configuration.topic_exchange_name, durable: true) 46 | end 47 | 48 | def delayed_exchange 49 | raise_if_delayed_not_allowed 50 | @delayed_exchange ||= channel.fanout(configuration.delayed_exchange_name, durable: true) 51 | end 52 | 53 | def delayed_queue 54 | # This works by hacking the dead letter exchange concept with a timeout. 55 | # We set up a delayed exchange that has a delayed queue. This queue, configured below, 56 | # sets its dead letter exchange to be the main exchange (topic_exchange above). 57 | # 58 | # This means that when a message send to the delayed queue is either nack'ed with no retry OR 59 | # its TTL expires, it will be sent to the configured dead letter exchange, which is the main topic_exchange. 60 | # 61 | # Since nothing is actually consuming messages on the delayed queue, the only way messages can be removed and 62 | # sent back to the main exchange is if their TTL expires. As you can see in Pwwka::Transmitter#send_delayed_message! 63 | # we set an expiration on the message and send it to the delayed exchange. This means that the delay time is the TTL, 64 | # so the messages sits in the delayed queue until its TTL/delay expires, and then it's sent onto the 65 | # main exchange for everyone to consume. Thus creating a delay. 66 | raise_if_delayed_not_allowed 67 | @delayed_queue ||= begin 68 | queue = channel.queue("pwwka_delayed_#{Pwwka.environment}", durable: true, 69 | arguments: { 70 | 'x-dead-letter-exchange' => configuration.topic_exchange_name, 71 | }) 72 | queue.bind(delayed_exchange) 73 | queue 74 | end 75 | end 76 | alias :create_delayed_queue :delayed_queue 77 | 78 | def raise_if_delayed_not_allowed 79 | unless configuration.allow_delayed? 80 | raise ConfigurationError, "Delayed messages are not allowed. Update your configuration to allow them." 81 | end 82 | end 83 | 84 | def connection_close 85 | begin 86 | channel.close 87 | rescue => e 88 | logf "ERROR Closing RabbitMQ channel: #{e}", at: :error 89 | raise e 90 | end 91 | 92 | begin 93 | connection.close 94 | rescue => e 95 | logf "ERROR Closing connection to RabbitMQ: #{e}", at: :error 96 | raise e 97 | end 98 | end 99 | end 100 | end 101 | -------------------------------------------------------------------------------- /lib/pwwka/configuration.rb: -------------------------------------------------------------------------------- 1 | require 'bunny' 2 | require 'mono_logger' 3 | 4 | module Pwwka 5 | class ConfigurationError < StandardError; end 6 | class Configuration 7 | 8 | attr_accessor :rabbit_mq_host 9 | attr_accessor :topic_exchange_name 10 | attr_accessor :delayed_exchange_name 11 | attr_accessor :logger 12 | attr_accessor :log_level 13 | attr_accessor :log_hooks 14 | attr_accessor :options 15 | attr_accessor :background_job_processor 16 | attr_accessor :send_message_resque_backoff_strategy 17 | attr_accessor :default_prefetch 18 | attr_accessor :process_name 19 | attr_reader :requeue_on_error 20 | attr_writer :app_id 21 | attr_writer :async_job_klass 22 | attr_writer :error_handling_chain 23 | 24 | def initialize 25 | @rabbit_mq_host = nil 26 | @topic_exchange_name = "pwwka.topics.#{Pwwka.environment}" 27 | @delayed_exchange_name = "pwwka.delayed.#{Pwwka.environment}" 28 | @logger = MonoLogger.new(STDOUT) 29 | @log_level = :info 30 | @log_hooks = {} 31 | @options = {} 32 | @send_message_resque_backoff_strategy = [5, #intermittent glitch? 33 | 60, # quick interruption 34 | 600, 600, 600] # longer-term outage? 35 | @requeue_on_error = false 36 | @keep_alive_on_handler_klass_exceptions = false 37 | @background_job_processor = :resque 38 | @default_prefetch = nil 39 | @receive_raw_payload = false 40 | @process_name = "" 41 | end 42 | 43 | def keep_alive_on_handler_klass_exceptions? 44 | @keep_alive_on_handler_klass_exceptions 45 | end 46 | 47 | def app_id 48 | if @app_id.to_s.strip == "" 49 | if defined?(Rails) 50 | if Rails.respond_to?(:application) && Rails.respond_to?(:version) 51 | app_klass = Rails.application.class 52 | app_parent = app_klass.module_parent 53 | app_parent.name 54 | else 55 | raise "'Rails' is defined, but it doesn't respond to #application or #version, so could not derive the app_id; you must explicitly set it" 56 | end 57 | else 58 | raise "Could not derive the app_id; you must explicitly set it" 59 | end 60 | else 61 | @app_id 62 | end 63 | end 64 | 65 | def async_job_klass 66 | @async_job_klass || background_jobs[background_job_processor] 67 | end 68 | 69 | def payload_logging 70 | @payload_logging || :info 71 | end 72 | 73 | def payload_logging=(new_payload_logging_level) 74 | @payload_logging = new_payload_logging_level 75 | end 76 | 77 | def allow_delayed? 78 | options[:allow_delayed] 79 | end 80 | 81 | def error_handling_chain 82 | @error_handling_chain ||= begin 83 | klasses = [ Pwwka::ErrorHandlers::IgnorePayloadFormatErrors ] 84 | if self.requeue_on_error 85 | klasses << Pwwka::ErrorHandlers::NackAndRequeueOnce 86 | else 87 | klasses << Pwwka::ErrorHandlers::NackAndIgnore 88 | end 89 | unless self.keep_alive_on_handler_klass_exceptions? 90 | klasses << Pwwka::ErrorHandlers::Crash 91 | end 92 | klasses 93 | end 94 | end 95 | 96 | def keep_alive_on_handler_klass_exceptions=(val) 97 | @keep_alive_on_handler_klass_exceptions = val 98 | if @keep_alive_on_handler_klass_exceptions 99 | @error_handling_chain.delete(Pwwka::ErrorHandlers::Crash) 100 | elsif !@error_handling_chain.include?(Pwwka::ErrorHandlers::Crash) 101 | @error_handling_chain << Pwwka::ErrorHandlers::Crash 102 | end 103 | end 104 | 105 | def requeue_on_error=(val) 106 | @requeue_on_error = val 107 | if @requeue_on_error 108 | index = error_handling_chain.index(Pwwka::ErrorHandlers::NackAndIgnore) 109 | if index 110 | @error_handling_chain[index] = Pwwka::ErrorHandlers::NackAndRequeueOnce 111 | end 112 | else 113 | index = error_handling_chain.index(Pwwka::ErrorHandlers::NackAndRequeueOnce) 114 | if index 115 | @error_handling_chain[index] = Pwwka::ErrorHandlers::NackAndIgnore 116 | end 117 | end 118 | end 119 | 120 | def default_prefetch=(val) 121 | @default_prefetch = val.nil? ? val : val.to_i 122 | end 123 | 124 | # Set this if you don't want the payload parsed. This can be useful is you are expecting a lot of malformed 125 | # JSON or if you aren't using JSON at all. Note that currently, setting this to true will prevent all 126 | # payloads from being logged 127 | def receive_raw_payload=(val) 128 | @receive_raw_payload = val 129 | @payload_parser = nil 130 | end 131 | 132 | # Returns a proc that, when called with the payload, parses it according to the configuration. 133 | # 134 | # By default, this will assume the payload is JSON, parse it, and return a HashWithIndifferentAccess. 135 | def payload_parser 136 | @payload_parser ||= if @receive_raw_payload 137 | ->(payload) { payload } 138 | else 139 | ->(payload) { 140 | ActiveSupport::HashWithIndifferentAccess.new(JSON.parse(payload)) 141 | } 142 | end 143 | end 144 | 145 | # True if we should omit the payload from the log 146 | # 147 | # ::level_of_message_with_payload the level of the message about to be logged 148 | def omit_payload_from_log?(level_of_message_with_payload) 149 | return true if @receive_raw_payload 150 | Pwwka::Logging::LEVELS[Pwwka.configuration.payload_logging.to_sym] > Pwwka::Logging::LEVELS[level_of_message_with_payload.to_sym] 151 | end 152 | 153 | private 154 | 155 | def background_jobs 156 | { 157 | resque: Pwwka::SendMessageAsyncJob, 158 | sidekiq: Pwwka::SendMessageAsyncSidekiqJob, 159 | } 160 | end 161 | end 162 | end 163 | -------------------------------------------------------------------------------- /lib/pwwka/error_handlers.rb: -------------------------------------------------------------------------------- 1 | module Pwwka 2 | module ErrorHandlers 3 | end 4 | end 5 | 6 | require_relative "error_handlers/chain" 7 | require_relative "error_handlers/base_error_handler" 8 | require_relative "error_handlers/crash" 9 | require_relative "error_handlers/nack_and_requeue_once" 10 | require_relative "error_handlers/nack_and_ignore" 11 | require_relative "error_handlers/ignore_payload_format_errors" 12 | -------------------------------------------------------------------------------- /lib/pwwka/error_handlers/base_error_handler.rb: -------------------------------------------------------------------------------- 1 | module Pwwka 2 | module ErrorHandlers 3 | class BaseErrorHandler 4 | include Pwwka::Logging 5 | def handle_error(receiver,queue_name,payload,delivery_info,exception) 6 | raise "subclass must implement" 7 | end 8 | 9 | private 10 | 11 | def log(message,queue_name,payload,delivery_info,exception) 12 | logf "%{message} on %{queue_name} -> %{payload}, %{routing_key}: %{exception}: %{backtrace}", { 13 | message: message, 14 | queue_name: queue_name, 15 | payload: payload, 16 | routing_key: delivery_info.routing_key, 17 | exception: exception, 18 | backtrace: exception.backtrace.join(";"), 19 | } 20 | end 21 | 22 | # Subclasses can call these methods instead of 23 | # using true/false to more clearly indicate their intent 24 | def keep_going 25 | true 26 | end 27 | 28 | def abort_chain 29 | false 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/pwwka/error_handlers/chain.rb: -------------------------------------------------------------------------------- 1 | module Pwwka 2 | module ErrorHandlers 3 | # Given a chain of error handlers, calls them until either 4 | # one returns false/aborts or we exhaust the chain of handlers 5 | class Chain 6 | include Pwwka::Logging 7 | def initialize(default_handler_chain=[]) 8 | @error_handlers = default_handler_chain 9 | end 10 | def handle_error(message_handler_klass,receiver,queue_name,payload,delivery_info,exception) 11 | logf "Error Processing Message in %{message_handler_klass} due to %{exception} from payload '%{payload}'", at: :error, message_handler_klass: message_handler_klass, exception: exception.message, payload: payload 12 | if message_handler_klass.respond_to?(:error_handler) 13 | @error_handlers.unshift(message_handler_klass.send(:error_handler)) 14 | end 15 | @error_handlers.reduce(true) { |keep_going,error_handler| 16 | begin 17 | logf "%{error_handler_class} is being evaluated as part of pwwka's error-handling chain", error_handler_class: error_handler 18 | if keep_going 19 | keep_going = error_handler.new.handle_error(receiver,queue_name,payload,delivery_info,exception) 20 | if keep_going 21 | logf "%{error_handler_class} has asked to continue pwwka's error-handling chain", error_handler_class: error_handler 22 | else 23 | logf "%{error_handler_class} has halted pwwka's error-handling chain", error_handler_class: error_handler 24 | end 25 | else 26 | logf "Skipping %{error_handler_class} as we were asked to abort pwwka's error-handling chain", error_handler_class: error_handler 27 | end 28 | keep_going 29 | rescue StandardError => exception 30 | logf "'%{error_handler_class}' aborting due to unhandled exception '%{exception}'", at: :fatal, error_handler_class: error_handler, exception: exception 31 | false 32 | end 33 | } 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/pwwka/error_handlers/crash.rb: -------------------------------------------------------------------------------- 1 | require_relative "base_error_handler" 2 | module Pwwka 3 | module ErrorHandlers 4 | class Crash < BaseErrorHandler 5 | def handle_error(receiver,queue_name,payload,delivery_info,e) 6 | raise Interrupt,"Exiting due to exception #{e.inspect}" 7 | abort_chain 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/pwwka/error_handlers/ignore_payload_format_errors.rb: -------------------------------------------------------------------------------- 1 | require_relative "base_error_handler" 2 | module Pwwka 3 | module ErrorHandlers 4 | class IgnorePayloadFormatErrors < BaseErrorHandler 5 | def handle_error(receiver,queue_name,payload,delivery_info,exception) 6 | if exception.kind_of?(JSON::JSONError) 7 | log("Ignoring JSON error",queue_name,payload,delivery_info,exception) 8 | receiver.nack(delivery_info.delivery_tag) 9 | abort_chain 10 | else 11 | keep_going 12 | end 13 | end 14 | end 15 | 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/pwwka/error_handlers/nack_and_ignore.rb: -------------------------------------------------------------------------------- 1 | require_relative "base_error_handler" 2 | module Pwwka 3 | module ErrorHandlers 4 | class NackAndIgnore < BaseErrorHandler 5 | def handle_error(receiver,queue_name,payload,delivery_info,exception) 6 | log("Error Processing Message",queue_name,payload,delivery_info,exception) 7 | receiver.nack(delivery_info.delivery_tag) 8 | keep_going 9 | end 10 | end 11 | 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/pwwka/error_handlers/nack_and_requeue_once.rb: -------------------------------------------------------------------------------- 1 | require_relative "base_error_handler" 2 | module Pwwka 3 | module ErrorHandlers 4 | class NackAndRequeueOnce < BaseErrorHandler 5 | def handle_error(receiver,queue_name,payload,delivery_info,exception) 6 | if delivery_info.redelivered 7 | log("Error Processing Message",queue_name,payload,delivery_info,exception) 8 | receiver.nack(delivery_info.delivery_tag) 9 | else 10 | log("Retrying an Error Processing Message",queue_name,payload,delivery_info,exception) 11 | receiver.nack_requeue(delivery_info.delivery_tag) 12 | end 13 | keep_going 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/pwwka/handling.rb: -------------------------------------------------------------------------------- 1 | require 'forwardable' 2 | module Pwwka 3 | 4 | module Handling 5 | extend Forwardable 6 | 7 | def_delegators :'Pwwka::Transmitter', :send_message!, :send_message_safely 8 | 9 | end 10 | 11 | end 12 | -------------------------------------------------------------------------------- /lib/pwwka/logging.rb: -------------------------------------------------------------------------------- 1 | module Pwwka 2 | module Logging 3 | 4 | delegate :fatal, :error, :warn, :info, :debug, to: :logger 5 | 6 | def logger 7 | Pwwka.configuration.logger 8 | end 9 | 10 | LEVELS = { 11 | fatal: 5, 12 | error: 4, 13 | warn: 3, 14 | info: 2, 15 | debug: 1, 16 | } 17 | 18 | def logf(format,params) 19 | level = params.delete(:at) || Pwwka.configuration.log_level 20 | params[:payload] = params["payload"] if params["payload"] 21 | if Pwwka.configuration.omit_payload_from_log?(level) 22 | params[:payload] = "[omitted]" if params[:payload] 23 | end 24 | message = format % params 25 | 26 | if Pwwka.configuration.log_hooks.select { |key, _value| message.match key }.each { |_key, value| value.call(message, params) }.empty? 27 | logger.send(level,message) 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/pwwka/message_queuer.rb: -------------------------------------------------------------------------------- 1 | module Pwwka 2 | # Queue messages for sending in a batch 3 | # Primarily used when multiple messages need to sent from within a 4 | # transaction block 5 | # 6 | # Example: 7 | # 8 | # # instantiate a message_queuer object 9 | # message_queuer = MessageQueuerService.new 10 | # ActiveRecord::Base.transaction do 11 | # # do a thing, then queue message 12 | # message_queuer.queue_message(payload: {this: 'that'}, routing_key: 'go.to.there') 13 | # 14 | # # do another thing, then queue a delayed message 15 | # message_queuer.queue_message(payload: {the: 'other'}, routing_key: 'go.somewhere.else', delayed: true, delay_by: 3000) 16 | # end 17 | # # send the queued messages if we make it out of the transaction alive 18 | # message_queuer.send_messages_safely 19 | 20 | 21 | class MessageQueuer 22 | 23 | include Handling 24 | 25 | attr_reader :message_queue 26 | 27 | def initialize() 28 | @message_queue = [] 29 | end 30 | 31 | def queue_message(payload: nil, routing_key: nil, delayed: false, delay_by: nil) 32 | raise 'Missing payload' if payload.nil? 33 | raise 'Missing routing_key' if routing_key.nil? 34 | message_queue.push({ 35 | payload: payload, 36 | routing_key: routing_key, 37 | delayed: delayed, 38 | delay_by: delay_by 39 | }) 40 | end 41 | 42 | def send_messages_safely 43 | message_queue.each do |message| 44 | delay_hash = {delayed: message[:delayed], delay_by: message[:delay_by]}.delete_if{|_,v|!v} 45 | send_message_safely(*message_arguments(message)) 46 | end 47 | clear_messages 48 | end 49 | 50 | def send_messages! 51 | message_queue.each do |message| 52 | payload, routing_key, options = *message_arguments(message) 53 | options ||= {} 54 | send_message!(payload, routing_key, **options) 55 | end 56 | clear_messages 57 | end 58 | 59 | def clear_messages 60 | @message_queue.clear 61 | end 62 | 63 | private 64 | def message_arguments(message) 65 | delay_hash = {delayed: message[:delayed], delay_by: message[:delay_by]}.delete_if{|_,v|!v} 66 | [message[:payload], message[:routing_key], (delay_hash.any? ? delay_hash : nil)].compact 67 | end 68 | 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /lib/pwwka/publish_options.rb: -------------------------------------------------------------------------------- 1 | require "securerandom" 2 | module Pwwka 3 | # Encaspulates the options we pass to `topic_exchange.publish` as well 4 | # as the various defaults and auto-generated values. 5 | class PublishOptions 6 | def initialize(routing_key: , 7 | message_id: :auto_generate, 8 | type: , 9 | headers:, 10 | expiration: nil) 11 | @options_hash = { 12 | routing_key: routing_key, 13 | message_id: message_id.to_s == "auto_generate" ? SecureRandom.uuid : message_id, 14 | content_type: "application/json; version=1", 15 | persistent: true, 16 | app_id: Pwwka.configuration.app_id 17 | } 18 | @options_hash[:type] = type unless type.nil? 19 | @options_hash[:headers] = headers unless headers.nil? 20 | @options_hash[:expiration] = expiration unless expiration.nil? 21 | end 22 | 23 | def message_id 24 | @options_hash[:message_id] 25 | end 26 | def to_h 27 | @options_hash.merge(timestamp: Time.now.to_i) 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/pwwka/queue_resque_job_handler.rb: -------------------------------------------------------------------------------- 1 | require 'active_support/core_ext/string/inflections' 2 | require 'resque' 3 | 4 | module Pwwka 5 | # A handler that simply queues the payload into a Resque job. This is useful 6 | # if the code that should respond to a message needs to be managed by Resque, e.g. 7 | # for the purposes of retry or better failure management. You can ask for the routing key and properties 8 | # by setting `PWWKA_QUEUE_EXTENDED_INFO` to `true` in your environment. 9 | # 10 | # You should be able to use this directly from your handler configuration, e.g. for a Heroku-style `Procfile`: 11 | # 12 | # my_handler: rake message_handler:receive HANDLER_KLASS=Pwwka::QueueResqueJobHandler JOB_KLASS=MyResqueJob QUEUE_NAME=my_queue ROUTING_KEY="my.key.completed" 13 | # my_handler_that_wants_more_info: rake message_handler:receive HANDLER_KLASS=Pwwka::QueueResqueJobHandler JOB_KLASS=MyOthgerResqueJob PWWKA_QUEUE_EXTENDED_INFO=true QUEUE_NAME=my_queue ROUTING_KEY="my.key.#" 14 | # 15 | # Note that this will not check the routing key, so you should be sure to specify the most precise ROUTING_KEY you can for handling the message. 16 | class QueueResqueJobHandler 17 | def self.handle!(delivery_info,properties,payload) 18 | job_klass = ENV["JOB_KLASS"].constantize 19 | args = [ 20 | job_klass, 21 | payload 22 | ] 23 | if ENV["PWWKA_QUEUE_EXTENDED_INFO"] == 'true' || job_klass_can_handle_args?(job_klass) 24 | args << delivery_info.routing_key 25 | args << properties.to_hash 26 | end 27 | Resque.enqueue(*args) 28 | end 29 | 30 | private 31 | 32 | def self.job_klass_can_handle_args?(job_klass) 33 | method = job_klass.method(:perform) 34 | return false if method.nil? 35 | method.arity == 3 36 | end 37 | end 38 | 39 | end 40 | -------------------------------------------------------------------------------- /lib/pwwka/receiver.rb: -------------------------------------------------------------------------------- 1 | module Pwwka 2 | class Receiver 3 | 4 | extend Pwwka::Logging 5 | 6 | attr_reader :channel_connector 7 | attr_reader :channel 8 | attr_reader :topic_exchange 9 | attr_reader :queue_name 10 | attr_reader :routing_key 11 | 12 | def initialize(queue_name, routing_key, prefetch: Pwwka.configuration.default_prefetch) 13 | @queue_name = queue_name 14 | @routing_key = routing_key 15 | @channel_connector = ChannelConnector.new(prefetch: prefetch, connection_name: "c: #{Pwwka.configuration.app_id} #{Pwwka.configuration.process_name}".strip) 16 | @channel = @channel_connector.channel 17 | @topic_exchange = @channel_connector.topic_exchange 18 | end 19 | 20 | def self.subscribe(handler_klass, queue_name, 21 | routing_key: "#.#", 22 | block: true, 23 | prefetch: Pwwka.configuration.default_prefetch, 24 | payload_parser: Pwwka.configuration.payload_parser) 25 | raise "#{handler_klass.name} must respond to `handle!`" unless handler_klass.respond_to?(:handle!) 26 | receiver = new(queue_name, routing_key, prefetch: prefetch) 27 | begin 28 | info "Receiving on #{queue_name}" 29 | receiver.topic_queue.subscribe(manual_ack: true, block: block) do |delivery_info, properties, payload| 30 | begin 31 | payload = payload_parser.(payload) 32 | handler_klass.handle!(delivery_info, properties, payload) 33 | receiver.ack(delivery_info.delivery_tag) 34 | logf "Processed Message on %{queue_name} -> %{payload}, %{routing_key}", queue_name: queue_name, payload: payload, routing_key: delivery_info.routing_key 35 | rescue => exception 36 | Pwwka::ErrorHandlers::Chain.new( 37 | Pwwka.configuration.error_handling_chain 38 | ).handle_error( 39 | handler_klass, 40 | receiver, 41 | queue_name, 42 | payload, 43 | delivery_info, 44 | exception) 45 | end 46 | end 47 | rescue Interrupt => _ 48 | # TODO: trap TERM within channel.work_pool 49 | info "Interrupting queue #{queue_name} subscriber safely" 50 | ensure 51 | receiver.channel_connector.connection_close 52 | end 53 | return receiver 54 | end 55 | 56 | def topic_queue 57 | @topic_queue ||= begin 58 | queue = channel.queue(queue_name, durable: true, arguments: {}) 59 | routing_key.split(',').each { |k| queue.bind(topic_exchange, routing_key: k) } 60 | queue 61 | end 62 | end 63 | 64 | def ack(delivery_tag) 65 | channel.acknowledge(delivery_tag, false) 66 | end 67 | 68 | def nack(delivery_tag) 69 | channel.nack(delivery_tag, false, false) 70 | end 71 | 72 | def nack_requeue(delivery_tag) 73 | channel.nack(delivery_tag, false, true) 74 | end 75 | 76 | def drop_queue 77 | topic_queue.purge 78 | topic_queue.delete 79 | end 80 | 81 | def test_teardown 82 | drop_queue 83 | topic_exchange.delete 84 | channel_connector.connection_close 85 | end 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /lib/pwwka/send_message_async_job.rb: -------------------------------------------------------------------------------- 1 | module Pwwka 2 | class SendMessageAsyncJob 3 | 4 | extend Pwwka::Logging 5 | 6 | @queue = 'pwwka_send_message_async' 7 | 8 | extend Resque::Plugins::ExponentialBackoff rescue nil # Optional 9 | @backoff_strategy = Pwwka.configuration.send_message_resque_backoff_strategy 10 | 11 | def self.perform(payload, routing_key, options = {}) 12 | 13 | type = options["type"] 14 | message_id = options["message_id"] || "auto_generate" 15 | headers = options["headers"] 16 | 17 | info("Sending message async #{routing_key}, #{payload}") 18 | message_id = message_id.to_sym if message_id == "auto_generate" 19 | Pwwka::Transmitter.send_message!( 20 | payload, 21 | routing_key, 22 | type: type, 23 | message_id: message_id, 24 | headers: headers, 25 | on_error: :raise) 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/pwwka/send_message_async_sidekiq_job.rb: -------------------------------------------------------------------------------- 1 | begin 2 | require 'sidekiq' 3 | rescue LoadError 4 | end 5 | 6 | module Pwwka 7 | class SendMessageAsyncSidekiqJob 8 | begin 9 | include Sidekiq::Worker 10 | extend Pwwka::Logging 11 | 12 | sidekiq_options queue: 'pwwka_send_message_async', retry: 3 13 | 14 | def perform(payload, routing_key, options = {}) 15 | type = options["type"] 16 | message_id = options["message_id"] || "auto_generate" 17 | headers = options["headers"] 18 | 19 | logger.info("Sending message async #{routing_key}, #{payload}") 20 | 21 | message_id = message_id.to_sym if message_id == "auto_generate" 22 | 23 | Pwwka::Transmitter.send_message!( 24 | payload, 25 | routing_key, 26 | type: type, 27 | message_id: message_id, 28 | headers: headers, 29 | on_error: :raise, 30 | ) 31 | end 32 | rescue NameError 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/pwwka/tasks.rb: -------------------------------------------------------------------------------- 1 | namespace :message_handler do 2 | desc "Start the message bus receiver" 3 | task :receive => :environment do 4 | raise "HANDLER_KLASS must be set" unless ENV['HANDLER_KLASS'] 5 | raise "QUEUE_NAME must be set" unless ENV['QUEUE_NAME'] 6 | handler_klass = ENV['HANDLER_KLASS'].constantize 7 | queue_name = "#{ENV['QUEUE_NAME']}_#{Rails.env}" 8 | routing_key = ENV['ROUTING_KEY'] || "#.#" 9 | prefetch = ENV['PREFETCH'] || Pwwka.configuration.default_prefetch 10 | 11 | Pwwka::Receiver.subscribe(handler_klass, queue_name, routing_key: routing_key, prefetch: prefetch) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/pwwka/test_handler.rb: -------------------------------------------------------------------------------- 1 | module Pwwka 2 | # A handler you can use to examine messages your app sends during tests. 3 | # 4 | # To use this: 5 | # 6 | # 1. Create an instance and arrange for `test_setup` to be called when 7 | # your tests are being setup (e.g.`def setup` or `before`) 8 | # 2. Arrange for `test_teardown` to be called during teardown of your tests 9 | # 3. Use the method `pop_message` to examine the message on the queue 10 | class TestHandler 11 | include Pwwka::Logging 12 | 13 | attr_reader :channel_connector 14 | 15 | def initialize 16 | @channel_connector = ChannelConnector.new 17 | end 18 | 19 | # call this method to create the queue used for testing 20 | # queue needs to be declared before the exchange is published to 21 | def test_setup 22 | test_queue 23 | true 24 | end 25 | 26 | def test_queue 27 | @test_queue ||= begin 28 | test_queue = channel_connector.channel.queue("test-queue", durable: true) 29 | test_queue.bind(channel_connector.topic_exchange, routing_key: "#.#") 30 | test_queue 31 | end 32 | end 33 | 34 | # Get the message on the queue as TestHandler::Message 35 | def pop_message 36 | delivery_info, properties, payload = test_queue.pop 37 | Message.new(delivery_info, 38 | properties, 39 | payload) 40 | end 41 | 42 | def get_topic_message_payload_for_tests 43 | deprecated!(:get_topic_message_payload_for_tests, 44 | "Use `pop_message.payload` instead") 45 | pop_message.payload 46 | end 47 | 48 | def get_topic_message_properties_for_tests 49 | deprecated!(:get_topic_message_properties_for_tests, 50 | "Use `pop_message.properties` instead") 51 | pop_message.properties 52 | end 53 | 54 | def get_topic_message_delivery_info_for_tests 55 | deprecated!(:get_topic_message_delivery_info_for_tests, 56 | "Use `pop_message.delivery_info` instead") 57 | pop_message.delivery_info 58 | end 59 | 60 | def purge_test_queue 61 | test_queue.purge 62 | channel_connector.delayed_queue.purge if channel_connector.configuration.allow_delayed? 63 | end 64 | 65 | def test_teardown 66 | test_queue.delete 67 | channel_connector.topic_exchange.delete 68 | # delayed messages 69 | if Pwwka.configuration.allow_delayed? 70 | channel_connector.delayed_queue.delete 71 | channel_connector.delayed_exchange.delete 72 | end 73 | 74 | channel_connector.connection_close 75 | end 76 | 77 | # Simple class to hold a popped message. 78 | # 79 | # You can either access the message contents directly, or splat 80 | # it for the most commonly-needed aspects: 81 | # 82 | # delivery_info, payload = @test_handler.pop_message 83 | class Message 84 | attr_reader :delivery_info, :properties, :payload 85 | def initialize(delivery_info, properties, payload) 86 | @delivery_info = delivery_info 87 | @properties = properties 88 | @raw_payload = payload 89 | @payload = JSON.parse(@raw_payload) 90 | end 91 | 92 | # Returns the delivery_info, payload, properties, and raw_payload for splat 93 | # magic. 94 | def to_ary 95 | [@delivery_info,@payload,@properties,@raw_payload] 96 | end 97 | end 98 | 99 | private 100 | 101 | def deprecated!(method,message) 102 | warn "#{method} is deprecated: #{message}" 103 | end 104 | 105 | end 106 | end 107 | -------------------------------------------------------------------------------- /lib/pwwka/transmitter.rb: -------------------------------------------------------------------------------- 1 | require_relative "publish_options" 2 | 3 | begin # optional dependency 4 | require 'resque' 5 | require 'resque-retry' 6 | rescue LoadError 7 | end 8 | 9 | module Pwwka 10 | # Primary interface for sending messages. 11 | # 12 | # Example: 13 | # 14 | # # Send a message, blowing up if there's any problem 15 | # Pwwka::Transmitter.send_message!({ user_id: @user.id }, "users.user.activated") 16 | # 17 | # # Send a message, logging if there's any problem 18 | # Pwwka::Transmitter.send_message_safely({ user_id: @user.id }, "users.user.activated") 19 | class Transmitter 20 | 21 | extend Pwwka::Logging 22 | include Pwwka::Logging 23 | 24 | DEFAULT_DELAY_BY_MS = 5000 25 | 26 | attr_reader :caller_manages_connector 27 | attr_reader :channel_connector 28 | 29 | def initialize(channel_connector: nil) 30 | if channel_connector 31 | @caller_manages_connector = true 32 | @channel_connector = channel_connector 33 | else 34 | @caller_manages_connector = false 35 | @channel_connector = ChannelConnector.new(connection_name: "p: #{Pwwka.configuration.app_id} #{Pwwka.configuration.process_name}".strip) 36 | end 37 | end 38 | 39 | # Send an important message that must go through. This method allows any raised exception 40 | # to pass through. 41 | # 42 | # payload:: Hash of what you'd like to include in your message 43 | # routing_key:: String routing key for the message 44 | # delayed:: Boolean send this message later 45 | # delay_by:: Integer milliseconds to delay the message 46 | # type:: A string describing the type. This + your configured app_id should be unique to your entire ecosystem. 47 | # message_id:: If specified (which generally you should not do), sets the id of the message. If omitted, a GUID is used. 48 | # headers:: A hash of arbitrary headers to include in the AMQP attributes 49 | # on_error:: What is the behavior of 50 | # - :ignore (aka as send_message_safely) 51 | # - :raise 52 | # - :resque -- use Resque to try to send the message later 53 | # - :retry_async -- use the configured background job processor to retry sending the message later 54 | # 55 | # Returns true 56 | # 57 | # Raises any exception generated by the innerworkings of this library. 58 | def self.send_message!(payload, routing_key, 59 | on_error: :raise, 60 | delayed: false, 61 | delay_by: nil, 62 | type: nil, 63 | message_id: :auto_generate, 64 | headers: nil, 65 | channel_connector: nil 66 | ) 67 | if delayed 68 | new(channel_connector: channel_connector).send_delayed_message!(*[payload, routing_key, delay_by].compact, type: type, headers: headers, message_id: message_id) 69 | else 70 | new(channel_connector: channel_connector).send_message!(payload, routing_key, type: type, headers: headers, message_id: message_id) 71 | end 72 | logf "AFTER Transmitting Message on %{routing_key} -> %{payload}",routing_key: routing_key, payload: payload 73 | true 74 | rescue => e 75 | 76 | logf "ERROR Transmitting Message on %{routing_key} -> %{payload} : %{error}", routing_key: routing_key, payload: payload, error: e, at: :error 77 | 78 | case on_error 79 | 80 | when :raise 81 | raise e 82 | 83 | when :resque, :retry_async 84 | begin 85 | send_message_async(payload, routing_key, delay_by_ms: delayed ? delay_by || DEFAULT_DELAY_BY_MS : 0) 86 | rescue => exception 87 | warn(exception.message) 88 | raise e 89 | end 90 | 91 | else # ignore 92 | end 93 | false 94 | end 95 | 96 | # Enqueue the message with the configured background processor. 97 | # - :delay_by_ms:: Integer milliseconds to delay the message. Default is 0. 98 | def self.send_message_async(payload, routing_key, 99 | delay_by_ms: 0, 100 | type: nil, 101 | message_id: :auto_generate, 102 | headers: nil) 103 | background_job_processor = Pwwka.configuration.background_job_processor 104 | job = Pwwka.configuration.async_job_klass 105 | 106 | if background_job_processor == :resque 107 | resque_args = [job, payload, routing_key] 108 | 109 | unless type == nil && message_id == :auto_generate && headers == nil 110 | # NOTE: (jdlubrano) 111 | # Why can't we pass these options all of the time? Well, if a user 112 | # of pwwka has configured their own async_job_klass that only has an 113 | # arity of 2 (i.e. payload and routing key), then passing these options 114 | # as an additional argument would break the user's application. In 115 | # order to maintain compatibility with preceding versions of Pwwka, 116 | # we need to ensure that the same arguments passed into this method 117 | # result in compatible calls to enqueue any Resque jobs. 118 | resque_args << { type: type, message_id: message_id, headers: headers } 119 | end 120 | 121 | if delay_by_ms.zero? 122 | Resque.enqueue(*resque_args) 123 | else 124 | Resque.enqueue_in(delay_by_ms/1000, *resque_args) 125 | end 126 | elsif background_job_processor == :sidekiq 127 | options = { delay_by_ms: delay_by_ms, type: type, message_id: message_id, headers: headers } 128 | job.perform_async(payload, routing_key, options) 129 | end 130 | end 131 | 132 | # Send a less important message that doesn't have to go through. This eats 133 | # any `StandardError` and logs it, returning false rather than blowing up. 134 | # 135 | # payload:: Hash of what you'd like to include in your message 136 | # routing_key:: String routing key for the message 137 | # delayed:: Boolean send this message later 138 | # delay_by:: Integer milliseconds to delay the message 139 | # 140 | # Returns true if the message was sent, false otherwise 141 | # @deprecated This is ignoring a message. ::send_message supports this explicitly. 142 | def self.send_message_safely(payload, routing_key, delayed: false, delay_by: nil, message_id: :auto_generate) 143 | send_message!(payload, routing_key, delayed: delayed, delay_by: delay_by, on_error: :ignore) 144 | end 145 | 146 | def send_message!(payload, routing_key, type: nil, headers: nil, message_id: :auto_generate) 147 | publish_options = Pwwka::PublishOptions.new( 148 | routing_key: routing_key, 149 | message_id: message_id, 150 | type: type, 151 | headers: headers 152 | ) 153 | logf "START Transmitting Message on id[%{id}] %{routing_key} -> %{payload}", id: publish_options.message_id, routing_key: routing_key, payload: payload 154 | channel_connector.topic_exchange.publish(payload.to_json, publish_options.to_h) 155 | # if it gets this far it has succeeded 156 | logf "END Transmitting Message on id[%{id}] %{routing_key} -> %{payload}", id: publish_options.message_id, routing_key: routing_key, payload: payload 157 | true 158 | ensure 159 | unless caller_manages_connector 160 | channel_connector.connection_close 161 | end 162 | end 163 | 164 | 165 | def send_delayed_message!(payload, routing_key, delay_by = DEFAULT_DELAY_BY_MS, type: nil, headers: nil, message_id: :auto_generate) 166 | channel_connector.raise_if_delayed_not_allowed 167 | publish_options = Pwwka::PublishOptions.new( 168 | routing_key: routing_key, 169 | message_id: message_id, 170 | type: type, 171 | headers: headers, 172 | expiration: delay_by 173 | ) 174 | logf "START Transmitting Delayed Message on id[%{id}] %{routing_key} -> %{payload}", id: publish_options.message_id, routing_key: routing_key, payload: payload 175 | channel_connector.create_delayed_queue 176 | channel_connector.delayed_exchange.publish(payload.to_json,publish_options.to_h) 177 | # if it gets this far it has succeeded 178 | logf "END Transmitting Delayed Message on id[%{id}] %{routing_key} -> %{payload}", id: publish_options.message_id, routing_key: routing_key, payload: payload 179 | true 180 | ensure 181 | unless caller_manages_connector 182 | channel_connector.connection_close 183 | end 184 | end 185 | 186 | end 187 | end 188 | -------------------------------------------------------------------------------- /lib/pwwka/version.rb: -------------------------------------------------------------------------------- 1 | module Pwwka 2 | VERSION = '1.0.0' 3 | end 4 | 5 | -------------------------------------------------------------------------------- /owners.json: -------------------------------------------------------------------------------- 1 | { 2 | "owners": [ 3 | { 4 | "team": "messaging" 5 | } 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /pwwka.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | $:.push File.expand_path("../lib", __FILE__) 3 | require 'pwwka/version' 4 | 5 | Gem::Specification.new do |s| 6 | s.name = "pwwka" 7 | s.version = Pwwka::VERSION 8 | s.platform = Gem::Platform::RUBY 9 | s.authors = ["Stitch Fix Engineering","Andrew Peterson","Bill Eisenhauer","Dave Copeland","David A McClain","Jonathan Dean","Nick Reavill","Simeon Willbanks"] 10 | s.email = ["opensource@stitchfix.com","andy@ndpsoftware.com","bill@stitchfix.com","davetron5000@gmail.com","david@stitchfix.com","jon@jonathandean.com","nick@fluxequalsrad.com","simeon@simeons.net" ] 11 | s.homepage = "https://github.com/stitchfix/pwwka" 12 | s.license = "MIT" 13 | s.summary = "Send and receive messages via RabbitMQ" 14 | s.description = "The purpose of this gem is to normalise the sending and 15 | receiving of messages between Rails apps using the shared RabbitMQ 16 | message bus" 17 | 18 | s.files = `git ls-files`.split("\n") 19 | s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") 20 | s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) } 21 | s.require_paths = ["lib"] 22 | s.add_runtime_dependency("bunny") 23 | s.add_runtime_dependency("activesupport", ">= 6.0.0") 24 | s.add_runtime_dependency("activemodel") 25 | s.add_runtime_dependency("mono_logger") 26 | s.add_development_dependency("rake") 27 | s.add_development_dependency("rspec") 28 | s.add_development_dependency("resque") 29 | s.add_development_dependency("resque-retry", "~> 1.5.3") 30 | s.add_development_dependency("sidekiq") 31 | s.add_development_dependency("simplecov") 32 | s.add_development_dependency("resqutils") 33 | s.add_development_dependency("rainbow") 34 | s.add_development_dependency("rspec_junit_formatter") 35 | s.add_development_dependency("pry-byebug") 36 | end 37 | -------------------------------------------------------------------------------- /spec/integration/interrupted_receivers_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper.rb' 2 | require_relative "support/integration_test_setup" 3 | require_relative "support/logging_receiver" 4 | require_relative "support/integration_test_helpers" 5 | 6 | describe "receivers being interrupted", :integration do 7 | include IntegrationTestHelpers 8 | 9 | before do 10 | @testing_setup = IntegrationTestSetup.new 11 | setup_receivers 12 | end 13 | 14 | before :each do 15 | WellBehavedReceiver.reset! 16 | end 17 | 18 | after do 19 | @testing_setup.kill_threads_and_clear_queues 20 | end 21 | 22 | it "an error in one receiver doesn't prevent others from getting messages" do 23 | Pwwka::Transmitter.send_message!({ sample: "payload", has: { deeply: true, nested: 4 }}, 24 | "pwwka.testing.foo") 25 | allow_receivers_to_process_queues 26 | 27 | expect(WellBehavedReceiver.messages_received.size).to eq(1) 28 | expect(@testing_setup.threads[WellBehavedReceiver].alive?).to eq(true) 29 | expect(@testing_setup.threads[InterruptingReceiver].alive?).to eq(false) 30 | end 31 | 32 | def setup_receivers 33 | [ 34 | [InterruptingReceiver, "interrupting_receiver_pwwkatesting"], 35 | [WellBehavedReceiver, "well_behaved_receiver_pwwkatesting"], 36 | ].each do |(klass, queue_name)| 37 | @testing_setup.make_queue_and_setup_receiver(klass,queue_name,"#") 38 | end 39 | end 40 | class InterruptingReceiver 41 | def self.handle!(delivery_info,properties,payload) 42 | raise Interrupt,'simulated interrupt would realy be a signal' 43 | end 44 | end 45 | class WellBehavedReceiver < LoggingReceiver 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /spec/integration/send_and_receive_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper.rb' 2 | require 'resqutils/spec/resque_helpers' 3 | require 'resqutils/spec/resque_matchers' 4 | require 'pwwka/queue_resque_job_handler' 5 | require 'active_support/time' 6 | 7 | require_relative "support/integration_test_setup" 8 | require_relative "support/logging_receiver" 9 | require_relative "support/integration_test_helpers" 10 | 11 | describe "sending and receiving messages", :integration do 12 | include IntegrationTestHelpers 13 | include Resqutils::Spec::ResqueHelpers 14 | 15 | let(:async_resque_queue) { 'pwwka_send_message_async' } 16 | let(:delayed_resque_queue) { :delayed } 17 | 18 | before do 19 | ENV["JOB_KLASS"] = MyTestJob.name 20 | ENV["PWWKA_QUEUE_EXTENDED_INFO"] = "true" 21 | @testing_setup = IntegrationTestSetup.new 22 | [ 23 | 24 | [AllReceiver , "all_receiver_pwwkatesting" , "#"] , 25 | [FooReceiver , "foo_receiver_pwwkatesting" , "pwwka.testing.foo"] , 26 | [MultiRoutingReceived , "multi_routing_receiver_pwwkatesting" , "pwwka.testing.bar,pwwka.testing.foo"] , 27 | [OtherFooReceiver , "other_foo_receiver_pwwkatesting" , "pwwka.testing.foo"] , 28 | [Pwwka::QueueResqueJobHandler , "queue_resque_job_handler_pwwkatesting" , "#" ] , 29 | 30 | ].each do |(klass, queue_name, routing_key)| 31 | @testing_setup.make_queue_and_setup_receiver(klass,queue_name,routing_key) 32 | end 33 | AllReceiver.reset! 34 | FooReceiver.reset! 35 | MultiRoutingReceived.reset! 36 | OtherFooReceiver.reset! 37 | clear_queue(async_resque_queue) 38 | clear_queue(delayed_resque_queue) 39 | clear_queue(MyTestJob) 40 | end 41 | 42 | after do 43 | @testing_setup.kill_threads_and_clear_queues 44 | ENV.delete("JOB_KLASS") 45 | ENV.delete("PWWKA_QUEUE_EXTENDED_INFO") 46 | end 47 | 48 | context "routing" do 49 | it "can send a message that gets routed to all receivers" do 50 | Pwwka::Transmitter.send_message!({ sample: "payload", has: { deeply: true, nested: 4 }}, 51 | "pwwka.testing.foo") 52 | allow_receivers_to_process_queues 53 | 54 | expect(AllReceiver.messages_received.size).to eq(1) 55 | expect(FooReceiver.messages_received.size).to eq(1) 56 | expect(MultiRoutingReceived.messages_received.size).to eq(1) 57 | expect(OtherFooReceiver.messages_received.size).to eq(1) 58 | @testing_setup.queues.each do |queue| 59 | expect(queue.message_count).to eq(0) 60 | end 61 | end 62 | it "can send a message that is only delivered to some handlers based on routing key" do 63 | Pwwka::Transmitter.send_message!({ sample: "payload", has: { deeply: true, nested: 4 }}, 64 | "pwwka.testing.bar") 65 | allow_receivers_to_process_queues 66 | 67 | expect(AllReceiver.messages_received.size).to eq(1) 68 | expect(FooReceiver.messages_received.size).to eq(0) 69 | expect(MultiRoutingReceived.messages_received.size).to eq(1) 70 | expect(OtherFooReceiver.messages_received.size).to eq(0) 71 | @testing_setup.queues.each do |queue| 72 | expect(queue.message_count).to eq(0) 73 | end 74 | end 75 | end 76 | 77 | context "metadata" do 78 | it "can access standard metadata" do 79 | Pwwka::Transmitter.send_message!({ sample: "payload", has: { deeply: true, nested: 4 }}, 80 | "pwwka.testing.foo") 81 | allow_receivers_to_process_queues 82 | 83 | expect(AllReceiver.metadata[0].message_id).not_to be_nil 84 | expect(AllReceiver.metadata[0].timestamp).to be_within(2.minutes).of(Time.now) 85 | expect(AllReceiver.metadata[0].content_type).to eq("application/json; version=1") 86 | expect(AllReceiver.metadata[0].app_id).to eq("MyAwesomeApp") 87 | end 88 | 89 | it "can access standard metadata on delayed jobs" do 90 | Pwwka::Transmitter.send_message!({ sample: "payload", has: { deeply: true, nested: 4 }}, 91 | "pwwka.testing.foo", 92 | delayed: true, 93 | delay_by: 100) 94 | allow_receivers_to_process_queues(200) 95 | 96 | expect(AllReceiver.metadata[0].message_id).not_to be_nil 97 | expect(AllReceiver.metadata[0].timestamp).to be_within(2.minutes).of(Time.now) 98 | expect(AllReceiver.metadata[0].content_type).to eq("application/json; version=1") 99 | expect(AllReceiver.metadata[0].app_id).to eq("MyAwesomeApp") 100 | end 101 | 102 | it "can access explicitly-provided metadata" do 103 | Pwwka::Transmitter.send_message!({ sample: "payload", has: { deeply: true, nested: 4 }}, 104 | "pwwka.testing.foo", 105 | type: "Customer", 106 | headers: { 107 | foo: "bar", 108 | blah: 42, 109 | }) 110 | allow_receivers_to_process_queues 111 | 112 | expect(AllReceiver.metadata[0].message_id).not_to be_nil 113 | expect(AllReceiver.metadata[0].timestamp).to be_within(2.minutes).of(Time.now) 114 | expect(AllReceiver.metadata[0].content_type).to eq("application/json; version=1") 115 | expect(AllReceiver.metadata[0].app_id).to eq("MyAwesomeApp") 116 | expect(AllReceiver.metadata[0].type).to eq("Customer") 117 | expect(AllReceiver.metadata[0].headers["foo"]).to eq("bar") 118 | expect(AllReceiver.metadata[0].headers["blah"]).to eq(42) 119 | end 120 | 121 | it "can access explicitly-provided metadata on delayed jobs" do 122 | Pwwka::Transmitter.send_message!({ sample: "payload", has: { deeply: true, nested: 4 }}, 123 | "pwwka.testing.foo", 124 | type: "Customer", 125 | headers: { 126 | foo: "bar", 127 | blah: 42, 128 | }, 129 | delayed: true, 130 | delay_by: 100) 131 | allow_receivers_to_process_queues(200) 132 | 133 | expect(AllReceiver.metadata[0].message_id).not_to be_nil 134 | expect(AllReceiver.metadata[0].timestamp).to be_within(2.minutes).of(Time.now) 135 | expect(AllReceiver.metadata[0].content_type).to eq("application/json; version=1") 136 | expect(AllReceiver.metadata[0].type).to eq("Customer") 137 | expect(AllReceiver.metadata[0].app_id).to eq("MyAwesomeApp") 138 | expect(AllReceiver.metadata[0].headers["foo"]).to eq("bar") 139 | expect(AllReceiver.metadata[0].headers["blah"]).to eq(42) 140 | end 141 | end 142 | 143 | context "sending messages from a background job" do 144 | it "can queue a job to send a message from a Resque job" do 145 | Pwwka::Transmitter.send_message_async({ sample: "payload", has: { deeply: true, nested: 4 }}, 146 | "pwwka.testing.bar") 147 | 148 | allow_receivers_to_process_queues # not expecting anything to be processed 149 | 150 | expect(AllReceiver.messages_received.size).to eq(0) 151 | 152 | process_resque_job(Pwwka::SendMessageAsyncJob, async_resque_queue) 153 | 154 | allow_receivers_to_process_queues 155 | 156 | expect(AllReceiver.messages_received.size).to eq(1) 157 | end 158 | 159 | it "can queue a job with optional arguments to send a message from a Resque job" do 160 | Pwwka::Transmitter.send_message_async( 161 | { sample: "payload", has: { deeply: true, nested: 4 }}, 162 | "pwwka.testing.bar", 163 | message_id: "setting this is a bad idea, but you can do it", 164 | headers: { 165 | "FOO" => "bar" 166 | }, 167 | type: "Customer" 168 | ) 169 | 170 | allow_receivers_to_process_queues # not expecting anything to be processed 171 | 172 | expect(AllReceiver.messages_received.size).to eq(0) 173 | 174 | process_resque_job(Pwwka::SendMessageAsyncJob, async_resque_queue) 175 | 176 | allow_receivers_to_process_queues 177 | 178 | expect(AllReceiver.messages_received.size).to eq(1) 179 | expect(AllReceiver.metadata[0].message_id).to eq("setting this is a bad idea, but you can do it") 180 | expect(AllReceiver.metadata[0].timestamp).to be_within(2.minutes).of(Time.now) 181 | expect(AllReceiver.metadata[0].content_type).to eq("application/json; version=1") 182 | expect(AllReceiver.metadata[0].type).to eq("Customer") 183 | expect(AllReceiver.metadata[0].app_id).to eq("MyAwesomeApp") 184 | expect(AllReceiver.metadata[0].headers["FOO"]).to eq("bar") 185 | end 186 | 187 | it "can queue a job to send a message to a specified Resque job queue" do 188 | async_job_klass = double(:async_job_klass) 189 | configuration = Pwwka::Configuration.new 190 | configuration.async_job_klass = async_job_klass 191 | 192 | allow(Pwwka).to receive(:configuration).and_return(configuration) 193 | 194 | allow(Resque).to receive(:enqueue) 195 | 196 | Pwwka::Transmitter.send_message_async({ sample: "payload", has: { deeply: true, nested: 4 }}, 197 | "pwwka.testing.bar") 198 | 199 | expect(Resque).to have_received(:enqueue).with(async_job_klass, anything, anything) 200 | end 201 | 202 | it "can queue a job to send a message with a delay" do 203 | Pwwka::Transmitter.send_message_async({ sample: "payload" }, 204 | "pwwka.testing.bar", 205 | delay_by_ms: 1_000) 206 | 207 | allow_receivers_to_process_queues # not expecting anything to be processed 208 | 209 | expect(AllReceiver.messages_received.size).to eq(0) 210 | 211 | process_resque_job(Pwwka::SendMessageAsyncJob, delayed_resque_queue) 212 | 213 | allow_receivers_to_process_queues 214 | 215 | expect(AllReceiver.messages_received.size).to eq(1) 216 | end 217 | end 218 | 219 | it "can send a message delayed" do 220 | Pwwka::Transmitter.send_message!({ sample: "payload", has: { deeply: true, nested: 4 }}, 221 | "pwwka.testing.foo", 222 | delayed: true, 223 | delay_by: 5_000) 224 | allow_receivers_to_process_queues(1_000) 225 | 226 | expect(AllReceiver.messages_received.size).to eq(0) 227 | expect(FooReceiver.messages_received.size).to eq(0) 228 | expect(MultiRoutingReceived.messages_received.size).to eq(0) 229 | expect(OtherFooReceiver.messages_received.size).to eq(0) 230 | 231 | allow_receivers_to_process_queues(5_000) 232 | expect(AllReceiver.messages_received.size).to eq(1) 233 | expect(FooReceiver.messages_received.size).to eq(1) 234 | expect(MultiRoutingReceived.messages_received.size).to eq(1) 235 | expect(OtherFooReceiver.messages_received.size).to eq(1) 236 | end 237 | 238 | it "can receive a message on a handler that just queues background jobs" do 239 | payload = { sample: "payload", has: { deeply: true, nested: 4 }} 240 | Pwwka::Transmitter.send_message!(payload, "foo.bar") 241 | 242 | allow_receivers_to_process_queues 243 | 244 | job = Resque.pop(:test_queue) 245 | aggregate_failures "job paylod" do 246 | expect(job["class"]).to eq(MyTestJob.name) 247 | expect(job["args"][0]).to eq({ "sample" => "payload", "has" => { "deeply" => true, "nested" => 4 }}) 248 | expect(job["args"][1]).to eq("foo.bar") 249 | 250 | # Expect a few things from the metadata for sanity 251 | expect(job["args"][2].keys).to include("content_type") 252 | expect(job["args"][2].keys).to include("message_id") 253 | expect(job["args"][2].keys).to include("timestamp") 254 | end 255 | end 256 | 257 | class MyTestJob 258 | @queue = "test_queue" 259 | 260 | def self.perform(payload,routing_key,properties) 261 | end 262 | end 263 | 264 | 265 | class AllReceiver < LoggingReceiver 266 | end 267 | class FooReceiver < AllReceiver 268 | end 269 | class OtherFooReceiver < AllReceiver 270 | end 271 | class MultiRoutingReceived < AllReceiver 272 | end 273 | end 274 | -------------------------------------------------------------------------------- /spec/integration/support/integration_test_helpers.rb: -------------------------------------------------------------------------------- 1 | module IntegrationTestHelpers 2 | def allow_receivers_to_process_queues(ms_to_sleep = 1_000) 3 | sleep (ms_to_sleep.to_f / 1_000.0) 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /spec/integration/support/integration_test_setup.rb: -------------------------------------------------------------------------------- 1 | class IntegrationTestSetup 2 | 3 | def threads 4 | @threads ||= {} 5 | end 6 | 7 | def queues 8 | @queues ||= [] 9 | end 10 | 11 | def make_queue_and_setup_receiver(klass,queue_name,routing_key) 12 | queue = channel.queue(queue_name, durable: true, arguments: {}) 13 | queue.bind(topic_exchange, routing_key: routing_key) 14 | queues << queue 15 | threads[klass] = Thread.new do 16 | Pwwka::Receiver.subscribe(klass, queue_name, routing_key: routing_key) 17 | end 18 | end 19 | 20 | def kill_threads_and_clear_queues 21 | threads.each do |_,thread| 22 | Thread.kill(thread) 23 | end 24 | queues.each do |queue| 25 | queue.purge 26 | queue.delete 27 | end 28 | end 29 | 30 | def channel_connector 31 | @channel_connector ||= Pwwka::ChannelConnector.new 32 | end 33 | def channel 34 | channel_connector.channel 35 | end 36 | def topic_exchange 37 | channel_connector.topic_exchange 38 | end 39 | 40 | end 41 | -------------------------------------------------------------------------------- /spec/integration/support/logging_receiver.rb: -------------------------------------------------------------------------------- 1 | class LoggingReceiver 2 | 3 | def self.reset! 4 | @messages_received = [] 5 | @metadata = [] 6 | end 7 | 8 | def self.messages_received; @messages_received ||= []; end 9 | def self.metadata; @metadata ||= []; end 10 | 11 | reset! 12 | 13 | def self.handle!(delivery_info,properties,payload) 14 | messages_received << [ delivery_info,properties,payload ] 15 | metadata << properties 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /spec/integration/test_handler_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper.rb' 2 | require_relative "support/integration_test_setup" 3 | require_relative "support/integration_test_helpers" 4 | 5 | describe "test handler for integration tests", :integration do 6 | include IntegrationTestHelpers 7 | 8 | subject(:test_handler) { Pwwka::TestHandler.new } 9 | before do 10 | test_handler.purge_test_queue 11 | test_handler.test_setup 12 | @testing_setup = IntegrationTestSetup.new 13 | @logger = Pwwka.configuration.logger 14 | end 15 | 16 | after do 17 | test_handler.test_teardown 18 | @testing_setup.kill_threads_and_clear_queues 19 | Pwwka.configuration.logger = @logger 20 | end 21 | 22 | it "allows introspecting messages that were sent" do 23 | first_payload = { sample: "payload", has: { deeply: true, nested: 4 }} 24 | second_payload = { other: :payload } 25 | 26 | Pwwka::Transmitter.send_message!(first_payload, "pwwka.testing.foo") 27 | Pwwka::Transmitter.send_message!(second_payload, "pwwka.testing.bar") 28 | 29 | first_message = test_handler.pop_message 30 | expect(first_message.delivery_info).not_to be_nil 31 | expect(first_message.properties).not_to be_nil 32 | expect(first_message.payload).to eq(JSON.parse(first_payload.to_json)) 33 | 34 | second_message = test_handler.pop_message 35 | expect(second_message.delivery_info).not_to be_nil 36 | expect(second_message.properties).not_to be_nil 37 | expect(second_message.payload).to eq(JSON.parse(second_payload.to_json)) 38 | 39 | end 40 | 41 | it "get_topic_message_payload_for_tests" do 42 | first_payload = { sample: "payload", has: { deeply: true, nested: 4 }} 43 | 44 | stringio = StringIO.new 45 | Pwwka.configuration.logger = Logger.new(stringio) 46 | Pwwka::Transmitter.send_message!(first_payload, "pwwka.testing.foo") 47 | 48 | payload = test_handler.get_topic_message_payload_for_tests 49 | expect(payload).to eq(JSON.parse(first_payload.to_json)) 50 | expect(stringio.string).to match(/get_topic_message_payload_for_tests is deprecated/) 51 | end 52 | 53 | it "get_topic_message_properties_for_tests" do 54 | first_payload = { sample: "payload", has: { deeply: true, nested: 4 }} 55 | 56 | stringio = StringIO.new 57 | Pwwka.configuration.logger = Logger.new(stringio) 58 | Pwwka::Transmitter.send_message!(first_payload, "pwwka.testing.foo") 59 | 60 | properties = test_handler.get_topic_message_properties_for_tests 61 | expect(properties).to_not be_nil 62 | expect(stringio.string).to match(/get_topic_message_properties_for_tests is deprecated/) 63 | end 64 | 65 | it "get_topic_message_delivery_info_for_tests" do 66 | first_payload = { sample: "payload", has: { deeply: true, nested: 4 }} 67 | 68 | stringio = StringIO.new 69 | Pwwka.configuration.logger = Logger.new(stringio) 70 | Pwwka::Transmitter.send_message!(first_payload, "pwwka.testing.foo") 71 | 72 | delivery_info = test_handler.get_topic_message_delivery_info_for_tests 73 | expect(delivery_info).to_not be_nil 74 | expect(stringio.string).to match(/get_topic_message_delivery_info_for_tests is deprecated/) 75 | end 76 | 77 | 78 | end 79 | -------------------------------------------------------------------------------- /spec/integration/unhandled_errors_in_receivers_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper.rb" 2 | require "logger" 3 | require "stringio" 4 | require_relative "support/integration_test_setup" 5 | require_relative "support/logging_receiver" 6 | require_relative "support/integration_test_helpers" 7 | 8 | class PoorlyBehavingErrorHandler < Pwwka::ErrorHandlers::BaseErrorHandler 9 | def handle_error(receiver,queue_name,payload,delivery_info,exception) 10 | raise "whoops, do I break everything behind me?" 11 | keep_going 12 | end 13 | end 14 | 15 | class ErrorHandlerThatWorksFine < Pwwka::ErrorHandlers::BaseErrorHandler 16 | def handle_error(receiver,queue_name,payload,delivery_info,exception) 17 | keep_going 18 | end 19 | end 20 | 21 | class EvilPayload 22 | def to_json 23 | "This is not JSON by any stretch" 24 | end 25 | end 26 | describe "receivers with unhandled errors", :integration do 27 | include IntegrationTestHelpers 28 | 29 | let(:log_data) { StringIO.new } 30 | let(:payload) { 31 | { "sample" => "payload", "has" => { "deeply" => true, "nested" => 4 }} 32 | } 33 | 34 | before do 35 | @testing_setup = IntegrationTestSetup.new 36 | Pwwka.configuration.instance_variable_set("@error_handling_chain",nil) 37 | Pwwka.configure do |c| 38 | c.requeue_on_error = false 39 | c.keep_alive_on_handler_klass_exceptions = false 40 | c.logger = Logger.new(log_data) 41 | c.payload_logging = :error 42 | end 43 | end 44 | 45 | after do 46 | @testing_setup.kill_threads_and_clear_queues 47 | end 48 | 49 | context "default configuration to crash on errors" do 50 | before do 51 | setup_receivers 52 | WellBehavedReceiver.reset! 53 | ExceptionThrowingReceiver.reset! 54 | IntermittentErrorReceiver.reset! 55 | ExceptionThrowingReceiverWithErrorHook.reset! 56 | end 57 | 58 | it "an error in one receiver doesn't prevent others from getting messages" do 59 | Pwwka::Transmitter.send_message!(payload, "pwwka.testing.foo") 60 | allow_receivers_to_process_queues 61 | 62 | expect(WellBehavedReceiver.messages_received.size).to eq(1) 63 | expect(ExceptionThrowingReceiver.messages_received.size).to eq(1) 64 | end 65 | 66 | it "logs the payload in the error in the log" do 67 | Pwwka::Transmitter.send_message!(payload, "pwwka.testing.foo") 68 | allow_receivers_to_process_queues 69 | expect(log_data.string).to match(/ERROR.*error processing message.*#{Regexp.escape(payload.inspect)}/i) 70 | end 71 | 72 | it "crashes the receiver that received an error" do 73 | Pwwka::Transmitter.send_message!(payload, "pwwka.testing.foo") 74 | allow_receivers_to_process_queues 75 | 76 | expect(@testing_setup.threads[ExceptionThrowingReceiver].alive?).to eq(false) 77 | end 78 | 79 | it "does not crash the receiver on a borked payload, but doesn't call handlers either" do 80 | Pwwka.configure do |c| 81 | c.requeue_on_error = true 82 | end 83 | Pwwka::Transmitter.send_message!(EvilPayload.new, 84 | "pwwka.testing.foo") 85 | allow_receivers_to_process_queues 86 | 87 | expect(@testing_setup.threads[ExceptionThrowingReceiver].alive?).to eq(true) 88 | expect(@testing_setup.threads[WellBehavedReceiver].alive?).to eq(true) 89 | expect(@testing_setup.threads[IntermittentErrorReceiver].alive?).to eq(true) 90 | expect(WellBehavedReceiver.messages_received.size).to eq(0) 91 | expect(ExceptionThrowingReceiver.messages_received.size).to eq(0) 92 | end 93 | 94 | it "does not crash the receiver that successfully processed a message" do 95 | Pwwka::Transmitter.send_message!(payload, "pwwka.testing.foo") 96 | allow_receivers_to_process_queues 97 | 98 | expect(@testing_setup.threads[WellBehavedReceiver].alive?).to eq(true) 99 | end 100 | 101 | it "crashes the receiver if it gets a failure that we retry" do 102 | Pwwka.configure do |c| 103 | c.requeue_on_error = true 104 | end 105 | Pwwka::Transmitter.send_message!(payload, "pwwka.testing.foo") 106 | allow_receivers_to_process_queues 107 | 108 | expect(@testing_setup.threads[IntermittentErrorReceiver].alive?).to eq(false) 109 | end 110 | end 111 | 112 | context "configured not to crash on error" do 113 | before do 114 | setup_receivers 115 | WellBehavedReceiver.reset! 116 | ExceptionThrowingReceiver.reset! 117 | IntermittentErrorReceiver.reset! 118 | ExceptionThrowingReceiverWithErrorHook.reset! 119 | Pwwka.configure do |c| 120 | c.keep_alive_on_handler_klass_exceptions = true 121 | end 122 | end 123 | it "does not crash the receiver that received an error" do 124 | Pwwka::Transmitter.send_message!(payload, "pwwka.testing.foo") 125 | allow_receivers_to_process_queues 126 | 127 | expect(@testing_setup.threads[ExceptionThrowingReceiver].alive?).to eq(true) 128 | end 129 | it "logs the payload in the error in the log" do 130 | Pwwka::Transmitter.send_message!(payload, "pwwka.testing.foo") 131 | allow_receivers_to_process_queues 132 | expect(log_data.string).to match(/ERROR.*error processing message.*#{Regexp.escape(payload.inspect)}/i) 133 | end 134 | end 135 | 136 | context "configured to requeue failed messages" do 137 | before do 138 | setup_receivers 139 | WellBehavedReceiver.reset! 140 | ExceptionThrowingReceiver.reset! 141 | IntermittentErrorReceiver.reset! 142 | ExceptionThrowingReceiverWithErrorHook.reset! 143 | Pwwka.configure do |c| 144 | c.requeue_on_error = true 145 | c.keep_alive_on_handler_klass_exceptions = true # only so we can check that the requeued message got sent; otherwise the receiver crashes and we can't test that 146 | end 147 | end 148 | it "requeues the message exactly once" do 149 | Pwwka::Transmitter.send_message!(payload, "pwwka.testing.foo") 150 | allow_receivers_to_process_queues 151 | 152 | expect(WellBehavedReceiver.messages_received.size).to eq(1) 153 | expect(ExceptionThrowingReceiver.messages_received.size).to eq(2) 154 | expect(ExceptionThrowingReceiver.messages_received[1][0].redelivered).to eq(true) 155 | expect(ExceptionThrowingReceiver.messages_received[1][2]).to eq(ExceptionThrowingReceiver.messages_received[0][2]) 156 | end 157 | it "logs the payload in the error in the log" do 158 | Pwwka::Transmitter.send_message!(payload, "pwwka.testing.foo") 159 | allow_receivers_to_process_queues 160 | expect(log_data.string).to match(/ERROR.*error processing message.*#{Regexp.escape(payload.inspect)}/i) 161 | end 162 | end 163 | 164 | context "handler with a custom error handler that ignores the exception" do 165 | before do 166 | setup_receivers(ExceptionThrowingReceiverWithErrorHook) 167 | WellBehavedReceiver.reset! 168 | ExceptionThrowingReceiver.reset! 169 | IntermittentErrorReceiver.reset! 170 | ExceptionThrowingReceiverWithErrorHook.reset! 171 | end 172 | 173 | it "does not crash the receiver" do 174 | Pwwka::Transmitter.send_message!({ sample: "payload", has: { deeply: true, nested: 4 }}, 175 | "pwwka.testing.foo") 176 | allow_receivers_to_process_queues 177 | 178 | expect(ExceptionThrowingReceiverWithErrorHook.messages_received.size).to eq(1) 179 | expect(@testing_setup.threads[ExceptionThrowingReceiverWithErrorHook].alive?).to eq(true) 180 | end 181 | end 182 | 183 | context "a custom error handler in the pwwka error handling chain throws its own exception" do 184 | before do 185 | @testing_setup.make_queue_and_setup_receiver(ExceptionThrowingReceiver,"exception_throwing_receiver_pwwkatesting","#") 186 | ExceptionThrowingReceiver.reset! 187 | 188 | Pwwka.configuration.instance_variable_set("@error_handling_chain", 189 | [ 190 | PoorlyBehavingErrorHandler, 191 | ErrorHandlerThatWorksFine 192 | ]) 193 | end 194 | 195 | after do 196 | Pwwka.configuration.instance_variable_set("@error_handling_chain",nil) 197 | end 198 | 199 | it "confirms subsequent error handlers do not run when there is an exception earlier in the chain" do 200 | Pwwka::Transmitter.send_message!({ sample: "payload", has: { deeply: true, nested: 4 }}, 201 | "pwwka.testing.foo") 202 | 203 | allow_receivers_to_process_queues 204 | 205 | expect(ExceptionThrowingReceiver.messages_received.size).to eq(1) 206 | expect(@testing_setup.threads[ExceptionThrowingReceiver].alive?).to eq(true) 207 | end 208 | end 209 | 210 | def setup_receivers(exception_throwing_receiver_klass=ExceptionThrowingReceiver) 211 | [ 212 | [exception_throwing_receiver_klass, "exception_throwing_receiver_pwwkatesting"], 213 | [WellBehavedReceiver, "well_behaved_receiver_pwwkatesting"], 214 | [IntermittentErrorReceiver, "intermittent_error_receiver_pwwkatesting"], 215 | ].each do |(klass, queue_name)| 216 | @testing_setup.make_queue_and_setup_receiver(klass,queue_name,"#") 217 | end 218 | end 219 | class ExceptionThrowingReceiver < LoggingReceiver 220 | def self.handle!(delivery_info,properties,payload) 221 | super(delivery_info,properties,payload) 222 | raise "OH NOES!" 223 | end 224 | end 225 | class NoOpHandler < Pwwka::ErrorHandlers::BaseErrorHandler 226 | def initialize(*) 227 | end 228 | def handle_error(receiver,queue_name,payload,delivery_info,exception) 229 | receiver.nack(delivery_info.delivery_tag) 230 | abort_chain 231 | end 232 | end 233 | class ExceptionThrowingReceiverWithErrorHook < LoggingReceiver 234 | def self.error_handler 235 | NoOpHandler 236 | end 237 | 238 | def self.handle!(delivery_info,properties,payload) 239 | super(delivery_info,properties,payload) 240 | raise "OH NOES!" 241 | end 242 | end 243 | class IntermittentErrorReceiver < LoggingReceiver 244 | def self.handle!(delivery_info,properties,payload) 245 | super(delivery_info,properties,payload) 246 | unless delivery_info.redelivered 247 | raise "OH NOES!" 248 | end 249 | end 250 | end 251 | class WellBehavedReceiver < LoggingReceiver 252 | end 253 | end 254 | -------------------------------------------------------------------------------- /spec/legacy/handling_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Pwwka::Handling, :legacy do 4 | 5 | class HKlass 6 | include Pwwka::Handling 7 | end 8 | 9 | describe "adding handler methods" do 10 | 11 | let(:handling_class) { HKlass.new } 12 | let(:payload) { { this: 'that'} } 13 | let(:routing_key) { 'sf.merch.style.updated' } 14 | 15 | it "should respond to 'send_message!'" do 16 | expect(Pwwka::Transmitter).to receive(:send_message!).with(payload, routing_key, delayed: false, delay_by: nil) 17 | handling_class.send_message!(payload, routing_key, delayed: false, delay_by: nil) 18 | end 19 | 20 | it "should respond to 'send_message_safely'" do 21 | expect(Pwwka::Transmitter).to receive(:send_message_safely).with(payload, routing_key, delayed: true, delay_by: 4000) 22 | handling_class.send_message_safely(payload, routing_key, delayed: true, delay_by: 4000) 23 | end 24 | 25 | end 26 | 27 | 28 | end 29 | -------------------------------------------------------------------------------- /spec/legacy/receiver_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper.rb' 2 | 3 | describe Pwwka::Receiver, :legacy do 4 | 5 | class HandyHandler 6 | def self.handle!(delivery_info, properties, payload) 7 | return "made it here" 8 | end 9 | end 10 | 11 | let(:payload) { { "this" => "that" } } 12 | let(:routing_key) { "this.that" } 13 | let(:queue_name) { "receiver_test" } 14 | let(:logger) { double(Logger) } 15 | 16 | describe "::subscribe" do 17 | 18 | before(:each) do 19 | @original_logger = Pwwka.configuration.logger 20 | Pwwka.configuration.logger = logger 21 | Pwwka.configuration.requeue_on_error = false 22 | allow(logger).to receive(:info) 23 | allow(logger).to receive(:error) 24 | @receiver = Pwwka::Receiver.subscribe(HandyHandler, "receiver_test", block: false) 25 | end 26 | 27 | after(:each) do 28 | Pwwka.configuration.logger = @original_logger 29 | Pwwka.configuration.requeue_on_error = false 30 | @receiver.test_teardown rescue nil 31 | end 32 | 33 | it "should receive the sent message and log about it" do 34 | expect(HandyHandler).to receive(:handle!).and_return("made it here") 35 | Pwwka::Transmitter.send_message!(payload, routing_key) 36 | expect(logger).to have_received(:info).with(/START Transmitting.*#{Regexp.escape(payload.to_s)}/) 37 | expect(logger).to have_received(:info).with(/END Transmitting.*#{Regexp.escape(payload.to_s)}/) 38 | expect(logger).to have_received(:info).with(/AFTER Transmitting.*#{Regexp.escape(payload.to_s)}/) 39 | end 40 | 41 | it "should nack the sent message if an error is raised" do 42 | exception = begin 43 | raise "blow up" 44 | rescue => ex 45 | ex 46 | end 47 | expect(HandyHandler).to receive(:handle!).and_raise(ex) 48 | expect(@receiver).not_to receive(:ack) 49 | expect(@receiver).to receive(:nack).with(instance_of(Fixnum)) 50 | Pwwka::Transmitter.send_message!(payload, routing_key) 51 | @receiver.test_teardown # force the message to be processed and exception handled 52 | expect(logger).to have_received(:info).with(/START Transmitting.*#{Regexp.escape(payload.to_s)}/) 53 | expect(logger).to have_received(:info).with(/END Transmitting.*#{Regexp.escape(payload.to_s)}/) 54 | expect(logger).to have_received(:info).with(/AFTER Transmitting.*#{Regexp.escape(payload.to_s)}/) 55 | expect(logger).to have_received(:error).with(/Error Processing Message.*#{Regexp.escape(payload.to_s)}.*#{Regexp.escape(exception.backtrace.join(';'))}/) 56 | end 57 | 58 | context "when we're configured to requeue on error" do 59 | before do 60 | Pwwka.configuration.requeue_on_error = true 61 | end 62 | it "should nack_requeue the sent message if it hasn't been retried before" do 63 | exception = begin 64 | raise "blow up" 65 | rescue => ex 66 | ex 67 | end 68 | expect(HandyHandler).to receive(:handle!).and_raise(ex) 69 | expect(@receiver).not_to receive(:ack) 70 | expect(@receiver).to receive(:nack_requeue).with(instance_of(Fixnum)) 71 | Pwwka::Transmitter.send_message!(payload, routing_key) 72 | @receiver.test_teardown # force the message to be processed and exception handled 73 | expect(logger).to have_received(:info).with(/START Transmitting.*#{Regexp.escape(payload.to_s)}/) 74 | expect(logger).to have_received(:info).with(/END Transmitting.*#{Regexp.escape(payload.to_s)}/) 75 | expect(logger).to have_received(:info).with(/AFTER Transmitting.*#{Regexp.escape(payload.to_s)}/) 76 | expect(logger).to have_received(:error).with(/Error Processing Message.*#{Regexp.escape(payload.to_s)}.*#{Regexp.escape(exception.backtrace.join(';'))}/) 77 | end 78 | 79 | it "should nack the sent message if it HAS been retried before" do 80 | # Super cheesy, but I don't see another way to access this 81 | allow_any_instance_of(Bunny::DeliveryInfo).to receive(:redelivered).and_return(true) 82 | exception = begin 83 | raise "blow up" 84 | rescue => ex 85 | ex 86 | end 87 | expect(HandyHandler).to receive(:handle!).and_raise(ex) 88 | expect(@receiver).not_to receive(:ack) 89 | expect(@receiver).to receive(:nack).with(instance_of(Fixnum)) 90 | Pwwka::Transmitter.send_message!(payload, routing_key) 91 | @receiver.test_teardown # force the message to be processed and exception handled 92 | expect(logger).to have_received(:info).with(/START Transmitting.*#{Regexp.escape(payload.to_s)}/) 93 | expect(logger).to have_received(:info).with(/END Transmitting.*#{Regexp.escape(payload.to_s)}/) 94 | expect(logger).to have_received(:info).with(/AFTER Transmitting.*#{Regexp.escape(payload.to_s)}/) 95 | expect(logger).to have_received(:error).with(/Error Processing Message.*#{Regexp.escape(payload.to_s)}.*#{Regexp.escape(exception.backtrace.join(';'))}/) 96 | end 97 | end 98 | 99 | end 100 | 101 | describe "instance methods and ::new" do 102 | 103 | before(:each) do 104 | @receiver = Pwwka::Receiver.new(queue_name, routing_key) 105 | end 106 | 107 | after(:each) do 108 | @receiver.test_teardown 109 | end 110 | 111 | describe "::new" do 112 | 113 | it "should initialize the expected attributes" do 114 | expect(@receiver.topic_exchange.name).to eq("topics-test") 115 | expect(@receiver.topic_exchange.type).to eq(:topic) 116 | end 117 | 118 | end 119 | 120 | describe "#topic_queue" do 121 | 122 | it "should return the queue with the right attributes" do 123 | queue = @receiver.topic_queue 124 | expect(queue.name).to eq(queue_name) 125 | expect(queue.instance_variable_get(:@bindings).count).to eq(1) 126 | end 127 | 128 | end 129 | 130 | describe "#ack" do 131 | 132 | it "should call the correct channel method" do 133 | delivery_tag = 1224 134 | expect(@receiver.channel).to receive(:acknowledge).with(delivery_tag, false) 135 | @receiver.ack(delivery_tag) 136 | end 137 | 138 | end 139 | 140 | describe "#nack" do 141 | 142 | it "should call the correct channel method" do 143 | delivery_tag = 1224 144 | expect(@receiver.channel).to receive(:nack).with(delivery_tag, false, false) 145 | @receiver.nack(delivery_tag) 146 | end 147 | 148 | end 149 | 150 | describe "#nack_requeue" do 151 | 152 | it "should call the correct channel method" do 153 | delivery_tag = 1224 154 | expect(@receiver.channel).to receive(:nack).with(delivery_tag, false, true) 155 | @receiver.nack_requeue(delivery_tag) 156 | end 157 | 158 | end 159 | 160 | describe "#prepare_payload" do 161 | 162 | context "when payload is JSON" do 163 | 164 | it "returns a hash with indifferent access" do 165 | payload = { something: "interesting" }.to_json 166 | 167 | result = @receiver.prepare_payload(payload) 168 | 169 | expect(result).to be_a(Hash) 170 | expect(result).to have_key("something") 171 | expect(result).to have_key(:something) 172 | end 173 | 174 | end 175 | 176 | context "when the paylad is not JSON" do 177 | 178 | it "defers the handling to a handler down the chain" do 179 | payload = "<>" 180 | 181 | result = @receiver.prepare_payload(payload) 182 | 183 | expect(result).to eq(payload) 184 | end 185 | 186 | end 187 | 188 | end 189 | 190 | end 191 | 192 | end 193 | -------------------------------------------------------------------------------- /spec/legacy/send_message_async_job_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper.rb' 2 | 3 | describe Pwwka::SendMessageAsyncJob, :legacy do 4 | 5 | let(:payload) { Hash[:this, "that"] } 6 | let(:routing_key) { "this.that.and.theother" } 7 | 8 | describe '::perform' do 9 | it 'calls Pwwwka::Transmitter to send the message' do 10 | expect(Pwwka::Transmitter).to receive(:send_message!).with(payload, routing_key, on_error: :raise) 11 | described_class.perform(payload, routing_key) 12 | end 13 | end 14 | 15 | end 16 | -------------------------------------------------------------------------------- /spec/legacy/transmitter_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper.rb' 2 | 3 | describe Pwwka::Transmitter, :legacy do 4 | 5 | before(:all) do 6 | @test_handler = Pwwka::TestHandler.new 7 | @test_handler.test_setup 8 | end 9 | 10 | after(:each) { @test_handler.purge_test_queue } 11 | after(:all) { @test_handler.test_teardown } 12 | 13 | let(:payload) { { "this" => "that" } } 14 | let(:routing_key) { "this.that.and.theother" } 15 | let(:exception) { RuntimeError.new('blow up')} 16 | let(:logger) { double(Logger) } 17 | 18 | before(:each) do 19 | @original_logger = Pwwka.configuration.logger 20 | Pwwka.configuration.logger = logger 21 | allow(logger).to receive(:info) 22 | allow(logger).to receive(:warn) 23 | allow(logger).to receive(:error) 24 | end 25 | 26 | after(:each) do 27 | Pwwka.configuration.logger = @original_logger 28 | end 29 | 30 | describe "#send_message!" do 31 | 32 | context "happy path" do 33 | it "should send the correct payload" do 34 | success = Pwwka::Transmitter.new.send_message!(payload, routing_key) 35 | expect(success).to be_truthy 36 | received_payload = @test_handler.pop_message.payload 37 | expect(received_payload["this"]).to eq("that") 38 | expect(logger).to have_received(:info).with("START Transmitting Message on #{routing_key} -> #{payload}") 39 | expect(logger).to have_received(:info).with("END Transmitting Message on #{routing_key} -> #{payload}") 40 | end 41 | 42 | it "should deliver on the expected routing key" do 43 | success = Pwwka::Transmitter.new.send_message!(payload, routing_key) 44 | expect(success).to be_truthy 45 | delivery_info = @test_handler.pop_message.delivery_info 46 | expect(delivery_info.routing_key).to eq(routing_key) 47 | end 48 | end 49 | 50 | it "should blow up if exception raised" do 51 | expect_any_instance_of(Pwwka::ChannelConnector).to receive(:topic_exchange).and_raise(exception) 52 | expect { 53 | Pwwka::Transmitter.new.send_message!(payload, routing_key) 54 | }.to raise_error(exception) 55 | expect(logger).to have_received(:info).with("START Transmitting Message on #{routing_key} -> #{payload}") 56 | expect(logger).not_to have_received(:info).with("END Transmitting Message on #{routing_key} -> #{payload}") 57 | end 58 | 59 | end 60 | 61 | describe "#send_delayed_message!" do 62 | 63 | context "happy path" do 64 | it "should send the correct payload" do 65 | success = Pwwka::Transmitter.new.send_delayed_message!(payload, routing_key, 1000) 66 | expect(success).to be_truthy 67 | expect(@test_handler.test_queue.message_count).to eq(0) 68 | sleep 5 69 | expect(@test_handler.test_queue.message_count).to eq(1) 70 | received_payload = @test_handler.pop_message.payload 71 | expect(received_payload["this"]).to eq("that") 72 | expect(logger).to have_received(:info).with("START Transmitting Delayed Message on #{routing_key} -> #{payload}") 73 | expect(logger).to have_received(:info).with("END Transmitting Delayed Message on #{routing_key} -> #{payload}") 74 | end 75 | 76 | it "should deliver on the expected routing key" do 77 | success = Pwwka::Transmitter.new.send_delayed_message!(payload, routing_key, 1) 78 | expect(success).to be_truthy 79 | sleep 1 80 | delivery_info = @test_handler.pop_message.delivery_info 81 | expect(delivery_info.routing_key).to eq(routing_key) 82 | end 83 | end 84 | 85 | it "should blow up if exception raised" do 86 | expect_any_instance_of(Pwwka::ChannelConnector).to receive(:create_delayed_queue).and_raise(exception) 87 | expect { 88 | Pwwka::Transmitter.new.send_delayed_message!(payload, routing_key, 1) 89 | }.to raise_error(exception) 90 | expect(logger).to have_received(:info).with("START Transmitting Delayed Message on #{routing_key} -> #{payload}") 91 | expect(logger).not_to have_received(:info).with("END Transmitting Delayed Message on #{routing_key} -> #{payload}") 92 | end 93 | 94 | context "delayed not configured" do 95 | it "should blow up if allow_delayed? is false" do 96 | expect(@test_handler.channel_connector.configuration).to receive(:allow_delayed?).at_least(:once).and_return(false) 97 | expect { 98 | Pwwka::Transmitter.new.send_delayed_message!(payload, routing_key, 1) 99 | }.to raise_error(Pwwka::ConfigurationError) 100 | end 101 | end 102 | 103 | end 104 | 105 | describe "::send_message!" do 106 | 107 | 108 | it "should send the correct payload" do 109 | Pwwka::Transmitter.send_message!(payload, routing_key) 110 | received_payload = @test_handler.pop_message.payload 111 | expect(received_payload["this"]).to eq("that") 112 | end 113 | 114 | it "should return true" do 115 | expect(Pwwka::Transmitter.send_message!(payload, routing_key)).to eq true 116 | end 117 | 118 | it "should ignore delay_by parameter (should it?)" do 119 | Pwwka::Transmitter.send_message!(payload, routing_key, delay_by: 5000) 120 | received_payload = @test_handler.pop_message.payload 121 | expect(received_payload["this"]).to eq("that") 122 | end 123 | 124 | context 'default exception policy' do 125 | it "should blow up if exception raised" do 126 | expect(Pwwka::ChannelConnector).to receive(:new).and_raise(exception) 127 | expect { 128 | Pwwka::Transmitter.send_message!(payload, routing_key) 129 | }.to raise_error(exception) 130 | end 131 | end 132 | 133 | context 'when on_error: :raise and exception raised' do 134 | before(:each) { expect(Pwwka::ChannelConnector).to receive(:new).and_raise(exception) } 135 | 136 | it "should blow up" do 137 | expect { 138 | Pwwka::Transmitter.send_message!(payload, routing_key, on_error: :raise) 139 | }.to raise_error(exception) 140 | end 141 | it "should not enqueue a resque job" do 142 | expect(Resque).not_to receive(:enqueue_in) 143 | expect { 144 | Pwwka::Transmitter.send_message!(payload, routing_key, on_error: :raise) 145 | }.to raise_error(exception) 146 | end 147 | end 148 | 149 | context 'when on_error: :ignore and exception raised' do 150 | before :each do 151 | expect(Pwwka::ChannelConnector).to receive(:new).and_raise("blow up") 152 | end 153 | it "should not blow up" do 154 | Pwwka::Transmitter.send_message!(payload, routing_key, on_error: :ignore) 155 | # check nothing has been queued 156 | expect(@test_handler.test_queue.pop.compact.count).to eq(0) 157 | end 158 | it "should return false" do 159 | expect(Pwwka::Transmitter.send_message!(payload, routing_key, on_error: :ignore)).to eql false 160 | end 161 | it "should not enqueue a resque job" do 162 | expect(Resque).not_to receive(:enqueue_in) 163 | Pwwka::Transmitter.send_message!(payload, routing_key, on_error: :ignore) 164 | end 165 | end 166 | 167 | context 'when on_error: :resque and exception raised' do 168 | before :each do 169 | allow(Resque).to receive(:enqueue_in) 170 | expect(Pwwka::ChannelConnector).to receive(:new).and_raise("blow up") 171 | end 172 | it "should return false" do 173 | expect(Pwwka::Transmitter.send_message!(payload, routing_key, on_error: :resque)).to eq false 174 | end 175 | 176 | it "should enqueue a Resque job if exception raised" do 177 | expect(Resque).to receive(:enqueue_in). 178 | with(0, Pwwka::SendMessageAsyncJob, payload, routing_key) 179 | 180 | Pwwka::Transmitter.send_message!(payload, routing_key, on_error: :resque) 181 | # check nothing has been queued 182 | expect(@test_handler.test_queue.pop.compact.count).to eq(0) 183 | end 184 | 185 | context 'and then resque fails' do 186 | it 'returns the original exception' do 187 | expect(Resque).to receive(:enqueue_in).and_raise('blow up in resque') 188 | expect { 189 | Pwwka::Transmitter.send_message!(payload, routing_key, on_error: :resque) 190 | }.to raise_exception('blow up') 191 | end 192 | end 193 | end 194 | 195 | 196 | context "delayed message" do 197 | 198 | it "should call send_delayed_message! if requested with delay_by" do 199 | expect_any_instance_of(Pwwka::Transmitter).to receive(:send_delayed_message!) 200 | .with(payload, routing_key, 2000) 201 | Pwwka::Transmitter.send_message!(payload, routing_key, delayed: true, delay_by: 2000) 202 | end 203 | 204 | it "should call send_delayed_message if requested without delay_by" do 205 | expect_any_instance_of(Pwwka::Transmitter).to receive(:send_delayed_message!) 206 | .with(payload, routing_key) 207 | Pwwka::Transmitter.send_message!(payload, routing_key, delayed: true) 208 | end 209 | 210 | it "should not call send_delayed_message if not requested" do 211 | expect_any_instance_of(Pwwka::Transmitter).not_to receive(:send_delayed_message!) 212 | expect_any_instance_of(Pwwka::Transmitter).to receive(:send_message!) 213 | Pwwka::Transmitter.send_message_safely(payload, routing_key) 214 | end 215 | 216 | it "should enqueue a Resque job if exception raised and on_error: :resque" do 217 | expect(Pwwka::ChannelConnector).to receive(:new).and_raise("blow up") 218 | 219 | expect(Resque).to receive(:enqueue_in). 220 | with(2, Pwwka::SendMessageAsyncJob, payload, routing_key) 221 | 222 | Pwwka::Transmitter.send_message!(payload, routing_key, delayed: true, delay_by: 2000, on_error: :resque) 223 | # check nothing has been queued 224 | expect(@test_handler.test_queue.pop.compact.count).to eq(0) 225 | end 226 | 227 | 228 | it "should enqueue a Resque job if exception raised and on_error: :resque without delay_by" do 229 | expect(Pwwka::ChannelConnector).to receive(:new).and_raise("blow up") 230 | 231 | expect(Resque).to receive(:enqueue_in). 232 | with(Pwwka::Transmitter::DEFAULT_DELAY_BY_MS/1000, Pwwka::SendMessageAsyncJob, payload, routing_key) 233 | 234 | Pwwka::Transmitter.send_message!(payload, routing_key, delayed: true, on_error: :resque) 235 | # check nothing has been queued 236 | expect(@test_handler.test_queue.pop.compact.count).to eq(0) 237 | end 238 | 239 | end 240 | end 241 | 242 | 243 | describe '::send_message_async' do 244 | context 'with no delay' do 245 | it 'queues the message' do 246 | expect(Resque).to receive(:enqueue_in). 247 | with(0, Pwwka::SendMessageAsyncJob, payload, routing_key) 248 | Pwwka::Transmitter.send_message_async(payload, routing_key) 249 | end 250 | end 251 | 252 | context 'with delay' do 253 | it 'queues the message' do 254 | expect(Resque).to receive(:enqueue_in). 255 | with(3, Pwwka::SendMessageAsyncJob, payload, routing_key) 256 | Pwwka::Transmitter.send_message_async(payload, routing_key, delay_by_ms: 3000) 257 | end 258 | end 259 | end 260 | 261 | describe "::send_message_safely" do 262 | 263 | it "should send the correct payload" do 264 | Pwwka::Transmitter.send_message_safely(payload, routing_key) 265 | received_payload = @test_handler.pop_message.payload 266 | expect(received_payload["this"]).to eq("that") 267 | end 268 | 269 | it "should not blow up if exception raised" do 270 | expect(Pwwka::ChannelConnector).to receive(:new).and_raise("blow up") 271 | Pwwka::Transmitter.send_message_safely(payload, routing_key) 272 | # check nothing has been queued 273 | expect(@test_handler.test_queue.pop.compact.count).to eq(0) 274 | end 275 | 276 | context "delayed message" do 277 | 278 | it "should call send_delayed_message! if requested with delay_by" do 279 | expect_any_instance_of(Pwwka::Transmitter).to receive(:send_delayed_message!) 280 | .with(payload, routing_key, 2000) 281 | Pwwka::Transmitter.send_message_safely(payload, routing_key, delayed: true, delay_by: 2000) 282 | end 283 | 284 | it "should call send_delayed_message if requested without delay_by" do 285 | expect_any_instance_of(Pwwka::Transmitter).to receive(:send_delayed_message!) 286 | .with(payload, routing_key) 287 | Pwwka::Transmitter.send_message_safely(payload, routing_key, delayed: true) 288 | end 289 | 290 | it "should not call send_delayed_message if not requested" do 291 | expect_any_instance_of(Pwwka::Transmitter).not_to receive(:send_delayed_message!) 292 | expect_any_instance_of(Pwwka::Transmitter).to receive(:send_message!) 293 | Pwwka::Transmitter.send_message_safely(payload, routing_key) 294 | end 295 | 296 | end 297 | 298 | end 299 | 300 | end 301 | -------------------------------------------------------------------------------- /spec/lib/pwwka/error_handlers/chain_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe Pwwka::ErrorHandlers::Chain do 4 | subject(:chain) { described_class.new(default_handler_chain) } 5 | 6 | describe "#handle_error" do 7 | context "when an error handler raises an unhandled exception" do 8 | let(:default_handler_chain) { [bad_error_handler_klass, good_error_handler_klass] } 9 | let(:bad_error_handler_klass) { double("bad error handler klass", new: bad_error_handler) } 10 | let(:bad_error_handler) { 11 | handler = double("bad error handler") 12 | allow(handler).to receive(:handle_error).and_raise("unhandled exception in error handler") 13 | handler 14 | } 15 | let(:good_error_handler_klass) { double("good error handler klass") } 16 | 17 | before { allow(bad_error_handler).to receive(:error_handler).and_raise("Wibble") } 18 | 19 | it "does not run subsequent error handlers" do 20 | expect(good_error_handler_klass).to_not receive(:new) 21 | 22 | chain.handle_error(double,double,double,double,double,double.as_null_object) 23 | end 24 | 25 | it "logs exceptions that occur in the error handling chain" do 26 | allow(chain.logger).to receive(:send).with(any_args) 27 | expect(chain.logger).to receive(:send).with(:fatal, /aborting due to unhandled exception/) 28 | 29 | chain.handle_error(double,double,double,double,double,double.as_null_object) 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | GEM_ROOT = File.expand_path(File.join(File.dirname(__FILE__),'..')) 2 | 3 | ENV['RAILS_ENV'] ||= 'test' 4 | 5 | require 'simplecov' 6 | require 'pry-byebug' 7 | 8 | SimpleCov.start do 9 | add_filter "/spec/" 10 | end 11 | 12 | require 'pwwka' 13 | require 'pwwka/test_handler' 14 | begin 15 | require 'active_support/core_ext/hash' 16 | rescue NameError 17 | require "active_support/isolated_execution_state" 18 | require 'active_support/core_ext/hash' 19 | end 20 | 21 | # These are required in pwwka proper, but they are guarded to not cause 22 | # an error if missing. Requiring here so their absence will fail the tests 23 | require 'resque' 24 | require 'resque-retry' 25 | require 'sidekiq' 26 | 27 | require 'support/test_configuration' 28 | 29 | test_configuration = TestConfiguration.new(File.join(GEM_ROOT,"docker-compose.yml")) 30 | 31 | RSpec.configure do |config| 32 | 33 | config.expect_with :rspec do |c| 34 | c.syntax = [:should,:expect] # should is needed to make a resque helper 35 | # from resqutils work 36 | end 37 | 38 | config.before(:suite) do 39 | Pwwka.configure do |c| 40 | c.topic_exchange_name = "topics-test" 41 | c.options[:allow_delayed] = true 42 | c.requeue_on_error = false 43 | c.rabbit_mq_host = "amqp://guest:guest@localhost:#{test_configuration.rabbit_port}" 44 | c.app_id = "MyAwesomeApp" 45 | c.process_name = "my_awesome_process" 46 | 47 | unless ENV["SHOW_PWWKA_LOG"] == "true" 48 | c.logger = MonoLogger.new("/dev/null") 49 | end 50 | end 51 | Resque.redis = Redis.new(port: test_configuration.resque_redis_port) 52 | end 53 | config.around(:each) do |example| 54 | if example.metadata[:integration] 55 | result = test_configuration.check_services 56 | unless result.up? 57 | fail result.error 58 | end 59 | end 60 | example.run 61 | Pwwka.configuration.receive_raw_payload = false 62 | end 63 | config.order = :random 64 | config.filter_run_excluding :legacy 65 | end 66 | 67 | -------------------------------------------------------------------------------- /spec/support/test_configuration.rb: -------------------------------------------------------------------------------- 1 | require "yaml" 2 | require "socket" 3 | require "timeout" 4 | require "rainbow" 5 | 6 | class TestConfiguration 7 | attr_reader :resque_redis_port, :rabbit_port 8 | def initialize(docker_compose_file) 9 | yaml = YAML.load_file(docker_compose_file) 10 | 11 | if ENV["CI"] == 'true' 12 | @resque_redis_port = 6379 13 | @rabbit_port = 5672 14 | else 15 | @resque_redis_port = (ENV["PWWKA_RESQUE_REDIS_PORT"] || yaml["services"]["resque"]["ports"].first.split(/:/)[0]).to_i 16 | @rabbit_port = (ENV["PWWKA_RABBIT_PORT"] || yaml["services"]["rabbit"]["ports"].first.split(/:/)[0]).to_i 17 | end 18 | end 19 | 20 | def check_services 21 | redis_running = is_port_open?("localhost",@resque_redis_port) 22 | rabbit_running = is_port_open?("localhost",@rabbit_port) 23 | if !(redis_running && rabbit_port) 24 | OpenStruct.new(error: "Rabbit and/or Redis is not running - you need to run `docker-compose up` in the root dir", 25 | up?: false) 26 | else 27 | OpenStruct.new(up?: true) 28 | end 29 | end 30 | 31 | private 32 | 33 | def is_port_open?(ip, port) 34 | begin 35 | Timeout::timeout(1) do 36 | begin 37 | s = TCPSocket.new(ip, port) 38 | s.close 39 | return true 40 | rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH 41 | return false 42 | end 43 | end 44 | rescue Timeout::Error 45 | end 46 | 47 | return false 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /spec/unit/channel_connector_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper.rb' 2 | 3 | # Most of this class is just interacting with Rabbit so it's covered 4 | # by the integration tests. 5 | describe Pwwka::ChannelConnector do 6 | let(:bunny_session) { instance_double(Bunny::Session) } 7 | subject(:channel_connector) { described_class.new } 8 | 9 | describe "initialize" do 10 | let(:bunny_channel) { instance_double(Bunny::Channel) } 11 | 12 | before do 13 | allow(Bunny).to receive(:new).and_return(bunny_session) 14 | allow(bunny_session).to receive(:start) 15 | allow(bunny_session).to receive(:close) 16 | allow(bunny_session).to receive(:create_channel).and_return(bunny_channel) 17 | allow(bunny_channel).to receive(:on_error) 18 | end 19 | 20 | it "sets a prefetch value if configured to do so" do 21 | expect(bunny_channel).to receive(:prefetch).with(10) 22 | 23 | described_class.new(prefetch: 10) 24 | end 25 | 26 | it "sets an on_error handler" do 27 | expect(bunny_channel).to receive(:on_error) 28 | described_class.new 29 | end 30 | 31 | it "does not set a prefetch value unless configured" do 32 | expect(bunny_channel).not_to receive(:prefetch).with(10) 33 | 34 | described_class.new 35 | end 36 | 37 | it "sets a connection_name if configured to do so" do 38 | expect(Bunny).to receive(:new).with( 39 | /amqp:\/\/guest:guest@localhost:/, 40 | {:client_properties=>{:connection_name=>"test_connection"}, 41 | :automatically_recover=>false, 42 | :allow_delayed=>true}) 43 | 44 | described_class.new(connection_name: "test_connection") 45 | end 46 | 47 | it "only contains default options if none provided" do 48 | expect(Bunny).to receive(:new).with( 49 | /amqp:\/\/guest:guest@localhost:/, 50 | {:automatically_recover=>false, :allow_delayed=>true}) 51 | 52 | described_class.new 53 | end 54 | 55 | context "error during connection start" do 56 | before do 57 | allow(bunny_session).to receive(:start).and_raise("Connection Error!") 58 | end 59 | it "closes the connection" do 60 | begin 61 | described_class.new 62 | rescue => ex 63 | end 64 | expect(bunny_session).to have_received(:close) 65 | end 66 | it "raises an error" do 67 | expect { 68 | described_class.new 69 | }.to raise_error(/Connection Error!/) 70 | end 71 | end 72 | 73 | end 74 | 75 | describe "raise_if_delayed_not_allowed" do 76 | let(:bunny_channel) { instance_double(Bunny::Channel) } 77 | 78 | before do 79 | allow(Bunny).to receive(:new).and_return(bunny_session) 80 | allow(bunny_session).to receive(:start) 81 | allow(bunny_session).to receive(:close) 82 | allow(bunny_session).to receive(:create_channel).and_return(bunny_channel) 83 | allow(bunny_channel).to receive(:on_error) 84 | @default_allow_delayed = Pwwka.configuration.options[:allow_delayed] 85 | end 86 | 87 | after do 88 | Pwwka.configuration.options[:allow_delayed] = @default_allow_delayed 89 | end 90 | 91 | context "delayed is configured" do 92 | it "does not blow up" do 93 | Pwwka.configuration.options[:allow_delayed] = true 94 | expect { 95 | channel_connector.raise_if_delayed_not_allowed 96 | }.not_to raise_error 97 | end 98 | end 99 | context "delayed is not configured" do 100 | it "blows up" do 101 | Pwwka.configuration.options[:allow_delayed] = false 102 | expect { 103 | channel_connector.raise_if_delayed_not_allowed 104 | }.to raise_error(Pwwka::ConfigurationError) 105 | end 106 | end 107 | end 108 | 109 | end 110 | -------------------------------------------------------------------------------- /spec/unit/configuration_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper.rb' 2 | 3 | module MyAmazingApp 4 | class Application 5 | end 6 | end 7 | describe Pwwka::Configuration do 8 | 9 | subject(:configuration) { described_class.new } 10 | before do 11 | @env = ENV["RAILS_ENV"] 12 | ENV["RAILS_ENV"] = "production" 13 | end 14 | after do 15 | ENV["RAILS_ENV"] = @env 16 | end 17 | 18 | describe "#topic_exchange_name" do 19 | it "is based on the Pwwka.environment" do 20 | expect(configuration.topic_exchange_name).to eq("pwwka.topics.production") 21 | end 22 | end 23 | 24 | describe "#payload_parser" do 25 | it "parses JSON by default" do 26 | payload = { foo: { bar: 42 } } 27 | expect(described_class.new.payload_parser.(payload.to_json)).to eq({ "foo" => { "bar" => 42 } }) 28 | end 29 | 30 | it "setting receive_raw_payload to true pases through the raw payload" do 31 | configuration.receive_raw_payload = true 32 | payload = "

This is some XML

" 33 | expect(configuration.payload_parser.(payload)).to eq(payload) 34 | end 35 | 36 | it "setting receive_raw_payload to true then false restores the JSON-parsing" do 37 | configuration.receive_raw_payload = true 38 | payload = { foo: { bar: 42 } } 39 | expect(configuration.payload_parser.(payload.to_json)).to eq(payload.to_json) 40 | configuration.receive_raw_payload = false 41 | expect(configuration.payload_parser.(payload.to_json)).to eq({ "foo" => { "bar" => 42 } }) 42 | end 43 | end 44 | 45 | describe "#delayed_exchange_name" do 46 | it "is based on the Pwwka.environment" do 47 | expect(configuration.delayed_exchange_name).to eq("pwwka.delayed.production") 48 | end 49 | end 50 | 51 | describe "#payload_logging" do 52 | it "is info by default" do 53 | expect(configuration.payload_logging).to eq(:info) 54 | end 55 | 56 | it "can be overridden" do 57 | configuration.payload_logging = :debug 58 | expect(configuration.payload_logging).to eq(:debug) 59 | end 60 | end 61 | 62 | describe "#app_id" do 63 | it "returns the value set explicitly" do 64 | configuration.app_id = "MyApp" 65 | expect(configuration.app_id).to eq("MyApp") 66 | end 67 | it "blows up when not set" do 68 | expect { 69 | configuration.app_id 70 | }.to raise_error(/Could not derive the app_id; you must explicitly set it/) 71 | end 72 | context "when inside a Rails app" do 73 | before do 74 | rails = Class.new do 75 | def self.application 76 | MyAmazingApp::Application.new 77 | end 78 | 79 | def self.version 80 | active_support_dependency = Bundler.locked_gems.dependencies.detect do |name, dep| 81 | name == "activesupport" 82 | end.last 83 | version_specification = active_support_dependency.requirement.to_s 84 | version_specification[/\d.+/] 85 | end 86 | end 87 | Object.const_set("Rails",rails) 88 | end 89 | after do 90 | Object.send(:remove_const,"Rails") 91 | end 92 | it "uses the Rails app name" do 93 | expect(configuration.app_id).to eq("MyAmazingApp") 94 | end 95 | end 96 | 97 | context "when Rails is defined, but not how we expect" do 98 | before do 99 | rails = Class.new 100 | Object.const_set("Rails",rails) 101 | end 102 | after do 103 | Object.send(:remove_const,"Rails") 104 | end 105 | it "blows up when not set" do 106 | expect { 107 | configuration.app_id 108 | }.to raise_error(/'Rails' is defined, but it doesn't respond to #application or #version, so could not derive the app_id; you must explicitly set it/) 109 | end 110 | end 111 | end 112 | 113 | describe "#error_handling_chain" do 114 | before do 115 | configuration.instance_variable_set("@error_handling_chain",nil) 116 | end 117 | context "implicit configuration" do 118 | context "when requeue_on_error" do 119 | context "when keep_alive_on_handler_klass_exceptions" do 120 | it "is NackAndRequeueOnce" do 121 | configuration.requeue_on_error = true 122 | configuration.keep_alive_on_handler_klass_exceptions = true 123 | expect(configuration.error_handling_chain).to eq([Pwwka::ErrorHandlers::IgnorePayloadFormatErrors,Pwwka::ErrorHandlers::NackAndRequeueOnce]) 124 | end 125 | end 126 | context "when not keep_alive_on_handler_klass_exceptions" do 127 | it "is NackAndRequeueOnce,Crash" do 128 | configuration.requeue_on_error = true 129 | configuration.keep_alive_on_handler_klass_exceptions = false 130 | expect(configuration.error_handling_chain).to eq([Pwwka::ErrorHandlers::IgnorePayloadFormatErrors,Pwwka::ErrorHandlers::NackAndRequeueOnce,Pwwka::ErrorHandlers::Crash]) 131 | end 132 | end 133 | end 134 | context "when not requeue_on_error" do 135 | context "when keep_alive_on_handler_klass_exceptions" do 136 | it "is NackAndIgnore" do 137 | configuration.requeue_on_error = false 138 | configuration.keep_alive_on_handler_klass_exceptions = true 139 | expect(configuration.error_handling_chain).to eq([Pwwka::ErrorHandlers::IgnorePayloadFormatErrors,Pwwka::ErrorHandlers::NackAndIgnore]) 140 | end 141 | end 142 | context "when not keep_alive_on_handler_klass_exceptions" do 143 | it "is NackAndIgnore,Crash" do 144 | configuration.requeue_on_error = false 145 | configuration.keep_alive_on_handler_klass_exceptions = false 146 | expect(configuration.error_handling_chain).to eq([Pwwka::ErrorHandlers::IgnorePayloadFormatErrors,Pwwka::ErrorHandlers::NackAndIgnore,Pwwka::ErrorHandlers::Crash]) 147 | end 148 | end 149 | end 150 | end 151 | end 152 | 153 | describe "#default_prefetch" do 154 | it "is nil by default" do 155 | expect(configuration.default_prefetch).to be_nil 156 | end 157 | 158 | it "is a number" do 159 | configuration.default_prefetch = 10 160 | expect(configuration.default_prefetch).to eq(10) 161 | configuration.default_prefetch = "10" 162 | expect(configuration.default_prefetch).to eq(10) 163 | end 164 | end 165 | 166 | describe "#background_job_processor" do 167 | it "is :resque by default" do 168 | expect(configuration.background_job_processor).to eq(:resque) 169 | end 170 | 171 | it "can be overridden" do 172 | configuration.background_job_processor = :sidekiq 173 | expect(configuration.background_job_processor).to eq(:sidekiq) 174 | end 175 | end 176 | 177 | describe "#async_job_klass" do 178 | it "is SendMessageAsyncJob by default" do 179 | expect(configuration.async_job_klass).to eq(Pwwka::SendMessageAsyncJob) 180 | end 181 | 182 | it "is SendMessageAsyncSidekiqJob when background_job_processor=:sidekiq" do 183 | configuration.background_job_processor = :sidekiq 184 | expect(configuration.async_job_klass).to eq(Pwwka::SendMessageAsyncSidekiqJob) 185 | end 186 | 187 | it "can be configured to a custom class" do 188 | class CustomBackgroundJob 189 | end 190 | configuration.async_job_klass = CustomBackgroundJob 191 | expect(configuration.async_job_klass).to eq(CustomBackgroundJob) 192 | end 193 | end 194 | end 195 | -------------------------------------------------------------------------------- /spec/unit/logging_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper.rb' 2 | 3 | describe Pwwka::Logging do 4 | 5 | class ForLogging 6 | extend Pwwka::Logging 7 | end 8 | 9 | it "returns the logger" do 10 | Pwwka.configure do |c| 11 | c.logger = MonoLogger.new(STDOUT) 12 | end 13 | expect(ForLogging.logger).to be_instance_of(MonoLogger) 14 | end 15 | 16 | %w(debug info error fatal).each do |severity| 17 | it "logs #{severity} messages at the class level" do 18 | expect(ForLogging.respond_to?(severity.to_sym)).to eq true 19 | end 20 | 21 | end 22 | 23 | describe "#logf" do 24 | let(:logger) { double(Logger) } 25 | 26 | before do 27 | @original_logger = Pwwka.configuration.logger 28 | @original_log_level = Pwwka.configuration.log_level 29 | Pwwka.configuration.logger = logger 30 | allow(logger).to receive(:info) 31 | allow(logger).to receive(:error) 32 | end 33 | 34 | after do 35 | Pwwka.configuration.logger = @original_logger 36 | Pwwka.configuration.log_level = @original_log_level 37 | Pwwka.configuration.payload_logging = @original_payload_logging 38 | end 39 | 40 | it "logs a printf-style string at info" do 41 | ForLogging.logf("This is %{test} some %{data}", test: "a test of", data: "data and stuff", ignored: :hopefully) 42 | expect(logger).to have_received(:info).with("This is a test of some data and stuff") 43 | end 44 | 45 | it "logs at a different level if configured to do so" do 46 | Pwwka.configuration.log_level = :debug 47 | allow(logger).to receive(:debug) 48 | 49 | ForLogging.logf("This is %{test} some %{data}", test: "a test of", data: "data and stuff", ignored: :hopefully) 50 | expect(logger).to have_received(:debug).with("This is a test of some data and stuff") 51 | end 52 | 53 | it "logs a printf-style string at info" do 54 | ForLogging.logf("This is %{test} some %{data}", test: "a test of", data: "data and stuff", ignored: :hopefully) 55 | expect(logger).to have_received(:info).with("This is a test of some data and stuff") 56 | end 57 | 58 | it "can log an not-info" do 59 | ForLogging.logf("This is %{test} some %{data}", test: "a test of", data: "data and stuff", ignored: :hopefully, at: :error) 60 | expect(logger).to have_received(:error).with("This is a test of some data and stuff") 61 | expect(logger).not_to have_received(:info) 62 | end 63 | 64 | context "payload-stripping" do 65 | [ 66 | :payload, 67 | "payload", 68 | ].each do |name| 69 | it "will strip payload (given as a #{name.class}) if configured" do 70 | Pwwka.configuration.payload_logging = :error 71 | Pwwka.configuration.receive_raw_payload = false 72 | ForLogging.logf("This is the payload: %{payload}", name => { foo: "bar" }) 73 | ForLogging.logf("This is also the payload: %{payload}", name => { foo: "bar" }, at: :error) 74 | expect(logger).to have_received(:info).with("This is the payload: [omitted]") 75 | expect(logger).to have_received(:error).with("This is also the payload: {:foo=>\"bar\"}") 76 | end 77 | 78 | it "will strip payload (given as a #{name.class}) of errors, too" do 79 | Pwwka.configuration.payload_logging = :fatal 80 | Pwwka.configuration.receive_raw_payload = false 81 | ForLogging.logf("This is the payload: %{payload}", name => { foo: "bar" }) 82 | ForLogging.logf("This is also the payload: %{payload}", name => { foo: "bar" }, at: :error) 83 | expect(logger).to have_received(:info).with("This is the payload: [omitted]") 84 | expect(logger).to have_received(:error).with("This is also the payload: [omitted]") 85 | end 86 | 87 | it "will strip payload (given as a #{name.class}) if we AREN'T parsing payloads" do 88 | Pwwka.configuration.payload_logging = :info 89 | Pwwka.configuration.receive_raw_payload = true 90 | ForLogging.logf("This is the payload: %{payload}", name => { foo: "bar" }) 91 | ForLogging.logf("This is also the payload: %{payload}", name => { foo: "bar" }, at: :error) 92 | expect(logger).to have_received(:info).with("This is the payload: [omitted]") 93 | expect(logger).to have_received(:error).with("This is also the payload: [omitted]") 94 | end 95 | end 96 | 97 | context "log_hook matching" do 98 | def test_func(message, params) 99 | end 100 | 101 | before do 102 | Pwwka.configuration.log_hooks = { "test message" => ->(message, params){ test_func(message, params) } } 103 | end 104 | 105 | after do 106 | Pwwka.configuration.log_hooks = {} 107 | end 108 | 109 | context "message matches hook" do 110 | it "overrides logging" do 111 | ForLogging.logf("test message", {}) 112 | 113 | expect(logger).not_to have_received(:info) 114 | end 115 | 116 | it "calls the hook" do 117 | allow(self).to receive(:test_func) 118 | test_params = { param: :test } 119 | 120 | ForLogging.logf("test message", test_params) 121 | 122 | expect(self).to have_received(:test_func).with("test message", test_params) 123 | end 124 | end 125 | 126 | context "message doesn't match hook" do 127 | it "logs as normal" do 128 | ForLogging.logf("other message", {}) 129 | 130 | expect(logger).to have_received(:info).with("other message") 131 | end 132 | end 133 | end 134 | end 135 | 136 | end 137 | end 138 | -------------------------------------------------------------------------------- /spec/unit/message_queuer_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Pwwka::MessageQueuer do 4 | 5 | let(:message_queuer) { Pwwka::MessageQueuer.new } 6 | let(:message_queuer_with_messages) { 7 | message_queuer = Pwwka::MessageQueuer.new 8 | message_queuer.queue_message(payload: payload1, routing_key: routing_key1) 9 | message_queuer.queue_message( 10 | payload: payload2, routing_key: routing_key2, delayed: true, delay_by: 3500 11 | ) 12 | message_queuer 13 | } 14 | let(:payload1) { {this: 'that'} } 15 | let(:routing_key1) { "thing.7.happened" } 16 | let(:payload2) { {shim: 'sham'} } 17 | let(:routing_key2) { "thing.8.happened" } 18 | 19 | describe "#queue_message" do 20 | 21 | it "should add a message to the queue" do 22 | expect(message_queuer.message_queue).to eq([]) 23 | message_queuer.queue_message(payload: payload1, routing_key: routing_key1) 24 | message_queuer.queue_message( 25 | payload: payload2, routing_key: routing_key2, delayed: true, delay_by: 3500 26 | ) 27 | expect(message_queuer.message_queue).to eq([{payload: payload1, routing_key: routing_key1, delayed: false, delay_by: nil}, {payload: payload2, routing_key: routing_key2, delayed: true, delay_by: 3500}]) 28 | end 29 | 30 | end 31 | 32 | 33 | describe "#send_messages_safely" do 34 | 35 | it "should send the queued messages" do 36 | allow(message_queuer_with_messages).to receive(:send_message_safely) 37 | message_queuer_with_messages.send_messages_safely 38 | expect(message_queuer_with_messages.message_queue).to eq([]) 39 | expect(message_queuer_with_messages).to have_received(:send_message_safely).with(payload1, routing_key1) 40 | expect(message_queuer_with_messages).to have_received(:send_message_safely).with(payload2, routing_key2, delayed: true, delay_by: 3500) 41 | end 42 | 43 | end 44 | 45 | describe "#send_messages!" do 46 | 47 | it "should send the queued messages" do 48 | allow(message_queuer_with_messages).to receive(:send_message!) 49 | message_queuer_with_messages.send_messages! 50 | expect(message_queuer_with_messages.message_queue).to eq([]) 51 | expect(message_queuer_with_messages).to have_received(:send_message!).with(payload1, routing_key1) 52 | expect(message_queuer_with_messages).to have_received(:send_message!).with(payload2, routing_key2, delayed: true, delay_by: 3500) 53 | end 54 | 55 | end 56 | 57 | describe "#clear_messages" do 58 | 59 | it "should clear the queued messages" do 60 | expect(message_queuer_with_messages.message_queue.size).to eq(2) 61 | message_queuer_with_messages.clear_messages 62 | expect(message_queuer_with_messages.message_queue).to eq([]) 63 | end 64 | 65 | end 66 | 67 | 68 | end 69 | -------------------------------------------------------------------------------- /spec/unit/queue_resque_job_handler_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'pwwka/queue_resque_job_handler' 3 | 4 | class MyLegacyTestJob 5 | def self.perform(payload) 6 | end 7 | end 8 | 9 | class MyNewTestJob 10 | def self.perform(payload, routing_key, properties) 11 | end 12 | end 13 | 14 | describe Pwwka::QueueResqueJobHandler do 15 | 16 | describe "::handle!" do 17 | let(:job_class) { MyLegacyTestJob } 18 | let(:routing_key) { "foo.bar.blah" } 19 | let(:delivery_info) { double("delivery info", routing_key: routing_key) } 20 | let(:properties_hash) { 21 | { 22 | "app_id" => "myapp", 23 | "timestamp" => "2015-12-12 13:22:99", 24 | "message_id" => "66", 25 | } 26 | } 27 | let(:properties) { Bunny::MessageProperties.new(properties_hash) } 28 | let(:payload) { 29 | { 30 | "this" => "is", 31 | "some" => true, 32 | "payload" => 99, 33 | } 34 | } 35 | 36 | before do 37 | allow(Resque).to receive(:enqueue) 38 | ENV["JOB_KLASS"] = MyLegacyTestJob.name 39 | end 40 | 41 | context "when not asking for more information explicitly" do 42 | it "should queue a resque job using JOB_KLASS and the payload" do 43 | described_class.handle!(delivery_info,properties,payload) 44 | expect(Resque).to have_received(:enqueue).with(MyLegacyTestJob,payload) 45 | end 46 | end 47 | 48 | context "when asking to NOT receive more information explicitly" do 49 | it "should queue a resque job using JOB_KLASS and the payload" do 50 | ENV["PWWKA_QUEUE_EXTENDED_INFO"] = 'false' 51 | described_class.handle!(delivery_info,properties,payload) 52 | expect(Resque).to have_received(:enqueue).with(MyLegacyTestJob,payload) 53 | end 54 | end 55 | 56 | context "when asking for more information via PWWKA_QUEUE_EXTENDED_INFO" do 57 | it "should queue a resque job using JOB_KLASS, payload, routing key, and properties as a hash" do 58 | # Note, using MyLegacyTestJob to ensure this doesn't trigger the method param examination logic and respects 59 | # the env var, even though it is not used correctly in this case. 60 | ENV["PWWKA_QUEUE_EXTENDED_INFO"] = 'true' 61 | described_class.handle!(delivery_info,properties,payload) 62 | expect(Resque).to have_received(:enqueue).with(MyLegacyTestJob,payload,routing_key,properties_hash) 63 | end 64 | end 65 | 66 | context "when not asking for more information via PWWKA_QUEUE_EXTENDED_INFO but for a job that can handle it" do 67 | it "should queue a resque job using JOB_KLASS, payload, routing key, and properties as a hash" do 68 | ENV["JOB_KLASS"] = MyNewTestJob.name 69 | ENV.delete("PWWKA_QUEUE_EXTENDED_INFO") 70 | described_class.handle!(delivery_info,properties,payload) 71 | expect(Resque).to have_received(:enqueue).with(MyNewTestJob,payload,routing_key,properties_hash) 72 | end 73 | end 74 | 75 | after do 76 | ENV.delete("JOB_KLASS") 77 | ENV.delete("PWWKA_QUEUE_EXTENDED_INFO") 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /spec/unit/receiver_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper.rb' 2 | 3 | describe Pwwka::Receiver do 4 | describe "#new" do 5 | let(:handler_klass) { double('HandlerKlass') } 6 | let(:channel_connector) { double(Pwwka::ChannelConnector, topic_exchange: topic_exchange, channel: channel)} 7 | let(:topic_exchange) { double("topic exchange") } 8 | let(:channel) { double('channel', queue: queue) } 9 | let(:queue) { double('queue') } 10 | let(:queue_name) { 'test_queue_name' } 11 | 12 | subject { 13 | described_class.subscribe( 14 | handler_klass, 15 | queue_name 16 | ) 17 | } 18 | 19 | before do 20 | allow(Pwwka::ChannelConnector).to receive(:new).and_return(channel_connector) 21 | allow(handler_klass).to receive(:handle!) 22 | allow(channel_connector).to receive(:connection_close) 23 | allow(queue).to receive(:bind) 24 | allow(queue).to receive(:subscribe).and_yield({}, {}, '{}') 25 | end 26 | 27 | it 'sets the correct connection_name' do 28 | subject 29 | expect(Pwwka::ChannelConnector).to have_received(:new).with(prefetch: nil, connection_name: "c: MyAwesomeApp my_awesome_process") 30 | end 31 | 32 | it 'closes the conenction on an error' do 33 | error = 'oh no' 34 | allow(handler_klass).to receive(:handle!).and_raise(error) 35 | begin; subject; rescue; end 36 | expect(channel_connector).to have_received(:connection_close) 37 | end 38 | 39 | it 'logs on interrupt' do 40 | allow(handler_klass).to receive(:handle!).and_raise(Interrupt) 41 | allow(described_class).to receive(:info) 42 | begin; subject; rescue; end 43 | expect(described_class).to have_received(:info).with(/Interrupting queue #{queue_name}/) 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /spec/unit/send_message_async_job_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper.rb' 2 | 3 | describe Pwwka::SendMessageAsyncJob do 4 | describe "::perform" do 5 | before do 6 | allow(Pwwka::Transmitter).to receive(:send_message!) 7 | end 8 | context "with just two arguments" do 9 | it "calls through to Pwwka::Transmitter, setting error handling to 'raise'" do 10 | described_class.perform({ "foo" => "bar"} , "some.routing.key") 11 | expect(Pwwka::Transmitter).to have_received(:send_message!).with( 12 | { "foo" => "bar" }, 13 | "some.routing.key", 14 | type: nil, 15 | message_id: :auto_generate, 16 | headers: nil, 17 | on_error: :raise 18 | ) 19 | end 20 | end 21 | context "with optional values" do 22 | it "passes them through to Pwwka::Transmitter" do 23 | described_class.perform( 24 | { "foo" => "bar"}, 25 | "some.routing.key", 26 | "type" => "Customer", 27 | "message_id" => "foobar", 28 | "headers" => { "x" => "y" } 29 | ) 30 | expect(Pwwka::Transmitter).to have_received(:send_message!).with( 31 | { "foo" => "bar" }, 32 | "some.routing.key", 33 | type: "Customer", 34 | message_id: "foobar", 35 | headers: { "x" => "y" }, 36 | on_error: :raise 37 | ) 38 | end 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /spec/unit/send_message_async_sidekiq_job_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper.rb' 2 | 3 | describe Pwwka::SendMessageAsyncSidekiqJob do 4 | describe "#perform" do 5 | before do 6 | allow(Pwwka::Transmitter).to receive(:send_message!) 7 | end 8 | 9 | context "with just two arguments" do 10 | it "calls through to Pwwka::Transmitter, setting error handling to 'raise'" do 11 | described_class.new.perform({ "foo" => "bar"} , "some.routing.key") 12 | expect(Pwwka::Transmitter).to have_received(:send_message!).with( 13 | { "foo" => "bar" }, 14 | "some.routing.key", 15 | type: nil, 16 | message_id: :auto_generate, 17 | headers: nil, 18 | on_error: :raise 19 | ) 20 | end 21 | end 22 | 23 | context "with optional values" do 24 | it "passes them through to Pwwka::Transmitter" do 25 | described_class.new.perform( 26 | { "foo" => "bar"}, 27 | "some.routing.key", 28 | "type" => "Customer", 29 | "message_id" => "foobar", 30 | "headers" => { "x" => "y" } 31 | ) 32 | expect(Pwwka::Transmitter).to have_received(:send_message!).with( 33 | { "foo" => "bar" }, 34 | "some.routing.key", 35 | type: "Customer", 36 | message_id: "foobar", 37 | headers: { "x" => "y" }, 38 | on_error: :raise 39 | ) 40 | end 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /spec/unit/test_handler_message_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Pwwka::TestHandler::Message do 4 | let(:delivery_info) { double("delivery info") } 5 | let(:properties) { double("properties") } 6 | let(:payload) { { foo: "bar" }.to_json } 7 | 8 | subject(:message) { described_class.new(delivery_info,properties,payload) } 9 | 10 | describe "attributes" do 11 | specify { expect(message.delivery_info).to eq(delivery_info) } 12 | specify { expect(message.properties).to eq(properties) } 13 | specify { expect(message.payload).to eq(JSON.parse(payload)) } 14 | end 15 | 16 | describe "splatting" do 17 | it "extracts pieces during a splat" do 18 | extracted_delivery_info,extracted_payload,extracted_properties,extracted_raw_payload = message 19 | expect(extracted_delivery_info).to eq(delivery_info) 20 | expect(extracted_properties).to eq(properties) 21 | expect(extracted_payload).to eq(JSON.parse(payload)) 22 | expect(extracted_raw_payload).to eq(payload) 23 | end 24 | end 25 | 26 | end 27 | -------------------------------------------------------------------------------- /spec/unit/transmitter_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper.rb' 2 | 3 | describe Pwwka::Transmitter do 4 | let(:topic_exchange) { double("topic exchange") } 5 | let(:delayed_exchange) { double("delayed exchange") } 6 | let(:channel_connector) { instance_double(Pwwka::ChannelConnector, topic_exchange: topic_exchange, delayed_exchange: delayed_exchange) } 7 | let(:logger) { double(Logger) } 8 | let(:payload) { 9 | { 10 | foo: { bar: "blah" }, 11 | crud: 12, 12 | } 13 | } 14 | let(:routing_key) { "sf.foo.bar" } 15 | 16 | 17 | before do 18 | @original_logger = Pwwka.configuration.logger 19 | Pwwka.configuration.logger = logger 20 | allow(logger).to receive(:info) 21 | allow(logger).to receive(:warn) 22 | allow(logger).to receive(:error) 23 | allow(Pwwka::ChannelConnector).to receive(:new).with(connection_name: "p: MyAwesomeApp my_awesome_process").and_return(channel_connector) 24 | allow(channel_connector).to receive(:connection_close) 25 | allow(topic_exchange).to receive(:publish) 26 | allow(delayed_exchange).to receive(:publish) 27 | end 28 | 29 | after do 30 | Pwwka.configuration.logger = @original_logger 31 | end 32 | 33 | subject(:transmitter) { described_class.new } 34 | 35 | describe ".send_message_async" do 36 | context "when configured background_job_processor is Resque" do 37 | before do 38 | allow(Resque).to receive(:enqueue_in) 39 | end 40 | context "with only basic required arguments" do 41 | it "queues a Resque job with no extra args" do 42 | delay_by_ms = 3_000 43 | described_class.send_message_async(payload,routing_key,delay_by_ms: delay_by_ms) 44 | expect(Resque).to have_received(:enqueue_in).with(delay_by_ms/1_000,Pwwka::SendMessageAsyncJob,payload,routing_key) 45 | end 46 | end 47 | context "with everything overridden" do 48 | it "queues a Resque job with the various arguments" do 49 | delay_by_ms = 3_000 50 | described_class.send_message_async( 51 | payload,routing_key, 52 | delay_by_ms: delay_by_ms, 53 | message_id: "snowflake id that is likely a bad idea, but if you must", 54 | type: "Customer", 55 | headers: { 56 | "custom" => "value", 57 | "other_custom" => "other_value", 58 | } 59 | ) 60 | expect(Resque).to have_received(:enqueue_in).with( 61 | delay_by_ms/1_000, 62 | Pwwka::SendMessageAsyncJob, 63 | payload, 64 | routing_key, 65 | message_id: "snowflake id that is likely a bad idea, but if you must", 66 | type: "Customer", 67 | headers: { 68 | "custom" => "value", 69 | "other_custom" => "other_value", 70 | } 71 | ) 72 | end 73 | end 74 | end 75 | 76 | context "when the configured background_job_processor is Sidekiq" do 77 | before do 78 | allow(Pwwka::SendMessageAsyncSidekiqJob).to receive(:perform_async) 79 | Pwwka.configuration.background_job_processor = :sidekiq 80 | end 81 | 82 | after do 83 | Pwwka::configuration.background_job_processor = :resque 84 | end 85 | 86 | context "with only basic required arguments" do 87 | it "queues a Sidekiq job with no extra arguments" do 88 | options = { delay_by_ms: 3_000, type: nil, message_id: :auto_generate, headers: nil} 89 | described_class.send_message_async(payload, routing_key, delay_by_ms: 3_000) 90 | expect(Pwwka::SendMessageAsyncSidekiqJob).to have_received(:perform_async) 91 | .with(payload, routing_key, options) 92 | end 93 | end 94 | 95 | context "with everything overridden" do 96 | it "queues a Sidekiq job with the various arguments" do 97 | described_class.send_message_async( 98 | payload,routing_key, 99 | delay_by_ms: 3_000, 100 | message_id: "snowflake id that is likely a bad idea, but if you must", 101 | type: "Customer", 102 | headers: { 103 | "custom" => "value", 104 | "other_custom" => "other_value", 105 | } 106 | ) 107 | expect(Pwwka::SendMessageAsyncSidekiqJob).to have_received(:perform_async).with( 108 | payload, 109 | routing_key, 110 | { 111 | delay_by_ms: 3_000, 112 | type: "Customer", 113 | message_id: "snowflake id that is likely a bad idea, but if you must", 114 | headers: { 115 | "custom" => "value", 116 | "other_custom" => "other_value", 117 | } 118 | } 119 | ) 120 | end 121 | end 122 | end 123 | end 124 | 125 | shared_examples "it passes through to an instance" do 126 | context "not using delayed flag" do 127 | it "calls through to send_message!" do 128 | expect_any_instance_of(described_class).to receive(:send_message!).with(payload,routing_key, type: nil, headers: nil, message_id: :auto_generate) 129 | described_class.send(method,payload,routing_key) 130 | end 131 | it "logs after sending" do 132 | described_class.send(method,payload,routing_key) 133 | expect(logger).to have_received(:info).with(/AFTER Transmitting Message on #{routing_key} ->/) 134 | end 135 | end 136 | context "using delayed flag" do 137 | 138 | it "logs after sending" do 139 | allow_any_instance_of(described_class).to receive(:send_delayed_message!) 140 | described_class.send(method,payload,routing_key, delayed: true) 141 | expect(logger).to have_received(:info).with(/AFTER Transmitting Message on #{routing_key} ->/) 142 | end 143 | 144 | context "explicitly setting delay time" do 145 | it "calls through to send_delayed_message! using the given delay time" do 146 | delay_by = 1_000 147 | expect_any_instance_of(described_class).to receive(:send_delayed_message!).with(payload,routing_key,delay_by, type: nil, headers: nil, message_id: :auto_generate) 148 | described_class.send(method,payload,routing_key,delayed: true, delay_by: delay_by) 149 | end 150 | end 151 | context "using the default delay time" do 152 | it "calls through to send_delayed_message! using its default delay time" do 153 | expect_any_instance_of(described_class).to receive(:send_delayed_message!).with(payload,routing_key, type: nil, headers: nil, message_id: :auto_generate) 154 | described_class.send(method,payload,routing_key,delayed: true) 155 | end 156 | end 157 | end 158 | end 159 | 160 | shared_examples "it sends standard and overridden data to the exchange" do 161 | it "publishes to the topic exchange" do 162 | expect(exchange).to have_received(:publish).with(payload.to_json, kind_of(Hash)) 163 | end 164 | 165 | it "passes the routing key" do 166 | expect(exchange).to have_received(:publish).with( 167 | payload.to_json, 168 | hash_including(routing_key: routing_key)) 169 | end 170 | 171 | it "sets the type" do 172 | expect(exchange).to have_received(:publish).with( 173 | payload.to_json, 174 | hash_including(type: "Customer")) 175 | end 176 | 177 | it "sets the headers" do 178 | expect(exchange).to have_received(:publish).with( 179 | payload.to_json, 180 | hash_including(headers: { "custom" => "value", "other_custom" => "other_value" })) 181 | end 182 | 183 | it "uses the overridden message id" do 184 | expect(exchange).to have_received(:publish).with( 185 | payload.to_json, 186 | hash_including(message_id: "snowflake id that is likely a bad idea, but if you must")) 187 | end 188 | 189 | it "sets the timestamp to now" do 190 | expect(exchange).to have_received(:publish).with( 191 | payload.to_json, 192 | hash_including(timestamp: a_timestamp_about_now)) 193 | end 194 | 195 | it "sets the app id to what's configured" do 196 | expect(exchange).to have_received(:publish).with( 197 | payload.to_json, 198 | hash_including(app_id: "MyAwesomeApp")) 199 | end 200 | 201 | it "sets the content type to JSON with a version" do 202 | expect(exchange).to have_received(:publish).with( 203 | payload.to_json, 204 | hash_including(content_type: "application/json; version=1")) 205 | end 206 | 207 | it "sets persistent true" do 208 | expect(exchange).to have_received(:publish).with( 209 | payload.to_json, 210 | hash_including(persistent: true)) 211 | end 212 | end 213 | 214 | shared_examples "it sends standard attributes and the payload to the exchange" do 215 | it "publishes to the topic exchange" do 216 | expect(exchange).to have_received(:publish).with(payload.to_json, kind_of(Hash)) 217 | end 218 | 219 | it "passes the routing key" do 220 | expect(exchange).to have_received(:publish).with( 221 | payload.to_json, 222 | hash_including(routing_key: routing_key)) 223 | end 224 | 225 | it "sets a default message id" do 226 | expect(exchange).to have_received(:publish).with( 227 | payload.to_json, 228 | hash_including(message_id: a_uuid)) 229 | end 230 | 231 | it "sets the timestamp to now" do 232 | expect(exchange).to have_received(:publish).with( 233 | payload.to_json, 234 | hash_including(timestamp: a_timestamp_about_now)) 235 | end 236 | 237 | it "sets the app id to what's configured" do 238 | expect(exchange).to have_received(:publish).with( 239 | payload.to_json, 240 | hash_including(app_id: "MyAwesomeApp")) 241 | end 242 | 243 | it "sets the content type to JSON with a version" do 244 | expect(exchange).to have_received(:publish).with( 245 | payload.to_json, 246 | hash_including(content_type: "application/json; version=1")) 247 | end 248 | 249 | it "sets persistent true" do 250 | expect(exchange).to have_received(:publish).with( 251 | payload.to_json, 252 | hash_including(persistent: true)) 253 | end 254 | 255 | it "does not set the type" do 256 | expect(exchange).to have_received(:publish).with( 257 | payload.to_json, 258 | hash_excluding(:type)) 259 | end 260 | 261 | it "does not set headers" do 262 | expect(exchange).to have_received(:publish).with( 263 | payload.to_json, 264 | hash_excluding(:headers)) 265 | end 266 | end 267 | 268 | RSpec::Matchers.define :a_uuid do |x| 269 | match { |actual| 270 | actual =~ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/ 271 | } 272 | end 273 | 274 | RSpec::Matchers.define :a_timestamp_about_now do |x| 275 | match { |actual| 276 | (actual - Time.now.to_i).abs < 1000 277 | } 278 | end 279 | 280 | 281 | describe ".send_message!" do 282 | context "no errors" do 283 | it_behaves_like "it passes through to an instance" do 284 | let(:method) { :send_message! } 285 | end 286 | end 287 | context "when there's an error" do 288 | before do 289 | allow_any_instance_of(described_class).to receive(:send_message!).and_raise("OH NOES") 290 | end 291 | it "logs the error" do 292 | begin 293 | described_class.send_message!(payload,routing_key) 294 | rescue => ex 295 | end 296 | expect(logger).to have_received(:error).with(/ERROR Transmitting Message on #{routing_key} ->/) 297 | expect(logger).to have_received(:error).with(/OH NOES/) 298 | end 299 | context "on_error: :ignore" do 300 | it "ignores the error" do 301 | expect { 302 | described_class.send_message!(payload,routing_key, on_error: :ignore) 303 | }.not_to raise_error 304 | end 305 | end 306 | context "on_error: :raise" do 307 | it "raises the error" do 308 | expect { 309 | described_class.send_message!(payload,routing_key, on_error: :raise) 310 | }.to raise_error(/OH NOES/) 311 | end 312 | end 313 | context "on_error: :resque" do 314 | it "queues a Resque job" do 315 | allow(Resque).to receive(:enqueue) 316 | described_class.send_message!(payload,routing_key, on_error: :resque) 317 | expect(Resque).to have_received(:enqueue).with(Pwwka::SendMessageAsyncJob,payload,routing_key) 318 | end 319 | context "when there is a problem queueing the resque job" do 320 | it "raises the original exception job" do 321 | allow(Resque).to receive(:enqueue).and_raise("NOPE") 322 | expect { 323 | described_class.send_message!(payload,routing_key, on_error: :resque) 324 | }.to raise_error(/OH NOES/) 325 | end 326 | it "logs the Resque error as a warning" do 327 | allow(Resque).to receive(:enqueue).and_raise("NOPE") 328 | begin 329 | described_class.send_message!(payload,routing_key, on_error: :resque) 330 | rescue => ex 331 | end 332 | expect(logger).to have_received(:warn).with(/NOPE/) 333 | end 334 | end 335 | end 336 | 337 | context "on_error: :retry_async" do 338 | context "when configured background_job_processor is Resque" do 339 | context "when the job is queued successfully" do 340 | before do 341 | allow(Resque).to receive(:enqueue) 342 | end 343 | 344 | it "queues a Resque job" do 345 | described_class.send_message!(payload, routing_key, on_error: :retry_async) 346 | expect(Resque).to have_received(:enqueue).with(Pwwka::SendMessageAsyncJob, payload, routing_key) 347 | end 348 | end 349 | 350 | context "when there is a problem queueing the Resque job" do 351 | before do 352 | allow(Resque).to receive(:enqueue).and_raise("NOPE") 353 | end 354 | 355 | it "raises the original exception job" do 356 | expect { 357 | described_class.send_message!(payload,routing_key, on_error: :retry_async) 358 | }.to raise_error(/OH NOES/) 359 | end 360 | 361 | it "logs the Resque error as a warning" do 362 | begin 363 | described_class.send_message!(payload,routing_key, on_error: :resque) 364 | rescue => ex 365 | end 366 | expect(logger).to have_received(:warn).with(/NOPE/) 367 | end 368 | end 369 | end 370 | 371 | context "when configured background_job_processor is Sidekiq" do 372 | before do 373 | Pwwka.configuration.background_job_processor = :sidekiq 374 | end 375 | 376 | after do 377 | Pwwka::configuration.background_job_processor = :resque 378 | end 379 | 380 | context "when the job is queued sucessfully" do 381 | it "queues a Sidekiq job" do 382 | allow(Pwwka::SendMessageAsyncSidekiqJob).to receive(:perform_async) 383 | described_class.send_message!(payload, routing_key, on_error: :retry_async) 384 | expect(Pwwka::SendMessageAsyncSidekiqJob).to have_received(:perform_async).with( 385 | payload, 386 | routing_key, 387 | {delay_by_ms: 0, headers: nil, message_id: :auto_generate, type: nil} 388 | ) 389 | end 390 | end 391 | 392 | context "when there is a problem queueing the Sidekiq job" do 393 | it "raises the original exception job" do 394 | allow(Pwwka::SendMessageAsyncSidekiqJob).to receive(:perform_async).and_raise("NOPE") 395 | expect { 396 | described_class.send_message!(payload, routing_key, on_error: :retry_async) 397 | }.to raise_error(/OH NOES/) 398 | end 399 | 400 | it "logs the Sidekiq error as a warning" do 401 | allow(Pwwka::SendMessageAsyncSidekiqJob).to receive(:perform_async).and_raise("NOPE") 402 | begin 403 | described_class.send_message!(payload,routing_key, on_error: :retry_async) 404 | rescue => ex 405 | end 406 | expect(logger).to have_received(:warn).with(/NOPE/) 407 | end 408 | end 409 | end 410 | end 411 | end 412 | end 413 | describe ".send_message_safely" do 414 | context "no errors" do 415 | it_behaves_like "it passes through to an instance" do 416 | let(:method) { :send_message_safely } 417 | end 418 | end 419 | context "when there's an error" do 420 | before do 421 | allow_any_instance_of(described_class).to receive(:send_message!).and_raise("OH NOES") 422 | end 423 | it "logs the error" do 424 | begin 425 | described_class.send_message_safely(payload,routing_key) 426 | rescue => ex 427 | end 428 | expect(logger).to have_received(:error).with(/ERROR Transmitting Message on #{routing_key} ->/) 429 | expect(logger).to have_received(:error).with(/OH NOES/) 430 | end 431 | it "ignores the error" do 432 | expect { 433 | described_class.send_message_safely(payload,routing_key) 434 | }.not_to raise_error 435 | end 436 | end 437 | end 438 | 439 | describe "#send_message!" do 440 | it "returns true" do 441 | expect(transmitter.send_message!(payload,routing_key)).to eq(true) 442 | end 443 | 444 | it "logs the start and end of the transmission" do 445 | transmitter.send_message!(payload,routing_key) 446 | expect(logger).to have_received(:info).with(/START Transmitting Message on id\[[\w\-\d]+\] #{routing_key} ->/) 447 | expect(logger).to have_received(:info).with(/END Transmitting Message on id\[[\w\-\d]+\] #{routing_key} ->/) 448 | end 449 | 450 | it "closes the channel connector" do 451 | transmitter.send_message!(payload,routing_key) 452 | expect(channel_connector).to have_received(:connection_close) 453 | end 454 | 455 | context 'when an error is raised' do 456 | subject { transmitter.send_message!(payload,routing_key) } 457 | let(:error) { 'oh no' } 458 | 459 | before do 460 | allow(topic_exchange).to receive(:publish).and_raise(error) 461 | end 462 | 463 | it 'should raise the error' do 464 | expect { subject } .to raise_error(error) 465 | end 466 | 467 | it 'should close the channel connector' do 468 | begin; subject; rescue; end 469 | expect(channel_connector).to have_received(:connection_close) 470 | end 471 | end 472 | 473 | context "with only basic required arguments" do 474 | before do 475 | transmitter.send_message!(payload,routing_key) 476 | end 477 | 478 | it_behaves_like "it sends standard attributes and the payload to the exchange" do 479 | let(:exchange) { topic_exchange } 480 | end 481 | end 482 | 483 | context "with everything overridden" do 484 | before do 485 | transmitter.send_message!( 486 | payload, 487 | routing_key, 488 | message_id: "snowflake id that is likely a bad idea, but if you must", 489 | type: "Customer", 490 | headers: { 491 | "custom" => "value", 492 | "other_custom" => "other_value", 493 | } 494 | ) 495 | end 496 | 497 | it_behaves_like "it sends standard and overridden data to the exchange" do 498 | let(:exchange) { topic_exchange } 499 | end 500 | end 501 | end 502 | 503 | describe "#send_delayed_message!" do 504 | context "delayed queue properly configured" do 505 | before do 506 | allow(channel_connector).to receive(:raise_if_delayed_not_allowed) 507 | allow(channel_connector).to receive(:create_delayed_queue) 508 | end 509 | 510 | it "creates the delayed queue" do 511 | transmitter.send_delayed_message!(payload,routing_key) 512 | expect(channel_connector).to have_received(:create_delayed_queue) 513 | end 514 | 515 | context 'when an error is raised' do 516 | subject { transmitter.send_delayed_message!(payload,routing_key) } 517 | let(:error) { 'oh no' } 518 | 519 | before do 520 | allow(delayed_exchange).to receive(:publish).and_raise(error) 521 | end 522 | 523 | it 'should raise the error' do 524 | expect { subject } .to raise_error(error) 525 | end 526 | 527 | it 'should close the channel connector' do 528 | begin; subject; rescue; end 529 | expect(channel_connector).to have_received(:connection_close) 530 | end 531 | end 532 | 533 | context "with only basic required arguments" do 534 | before do 535 | transmitter.send_delayed_message!(payload,routing_key,5_000) 536 | end 537 | 538 | it_behaves_like "it sends standard attributes and the payload to the exchange" do 539 | let(:exchange) { delayed_exchange } 540 | end 541 | 542 | it "passes an expiration value" do 543 | expect(delayed_exchange).to have_received(:publish).with( 544 | payload.to_json, 545 | hash_including(expiration: 5_000)) 546 | end 547 | end 548 | 549 | context "with everything overridden" do 550 | before do 551 | transmitter.send_delayed_message!( 552 | payload, 553 | routing_key, 554 | message_id: "snowflake id that is likely a bad idea, but if you must", 555 | type: "Customer", 556 | headers: { 557 | "custom" => "value", 558 | "other_custom" => "other_value", 559 | } 560 | ) 561 | end 562 | 563 | it_behaves_like "it sends standard and overridden data to the exchange" do 564 | let(:exchange) { delayed_exchange } 565 | end 566 | end 567 | end 568 | context "delayed queue not configured" do 569 | before do 570 | allow(channel_connector).to receive(:raise_if_delayed_not_allowed).and_raise("NOPE") 571 | end 572 | it "blows up" do 573 | expect { 574 | transmitter.send_delayed_message!(payload,routing_key) 575 | }.to raise_error(/NOPE/) 576 | end 577 | end 578 | end 579 | 580 | context "application manages connection" do 581 | let(:managed_connector) { instance_double(Pwwka::ChannelConnector, 582 | topic_exchange: double(:topic_exchange).as_null_object, 583 | delayed_exchange: double(:delayed_exchange).as_null_object 584 | ).as_null_object } 585 | describe ".send_message!" do 586 | 587 | context "send immediate" do 588 | it "doesn't open a new connection" do 589 | described_class.send_message!(payload, routing_key, delayed: false, channel_connector: managed_connector) 590 | 591 | expect(Pwwka::ChannelConnector).not_to have_received(:new) 592 | end 593 | 594 | it "doesn't close a passed in connection" do 595 | described_class.send_message!(payload, routing_key, delayed: false, channel_connector: managed_connector) 596 | 597 | expect(managed_connector).not_to have_received(:connection_close) 598 | end 599 | end 600 | 601 | context "send delayed" do 602 | it "doesn't open a new connection" do 603 | described_class.send_message!(payload, routing_key, delayed: true, channel_connector: managed_connector) 604 | 605 | expect(Pwwka::ChannelConnector).not_to have_received(:new) 606 | end 607 | 608 | it "doesn't close a passed in connection" do 609 | described_class.send_message!(payload, routing_key, delayed: true, channel_connector: managed_connector) 610 | 611 | expect(managed_connector).not_to have_received(:connection_close) 612 | end 613 | end 614 | end 615 | end 616 | end 617 | --------------------------------------------------------------------------------