advanced-sneakers-activejob 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: b1a41d60fe8a0f6041331ada53384c54f3316a3f9c08fd94cf21949fd5467073
4
+ data.tar.gz: 16f1bd88c90e7edde21a1231420f0415c8e07d466226b51270ae4085f6d3b215
5
+ SHA512:
6
+ metadata.gz: c68cc556832c0433e137a399e43011b7e9e884ca50c12beed290b1cc065d84c31ba5e9fe620527c1b6adfb6eca49b251d4e5c4322b6dd34186194283b7ffcee2
7
+ data.tar.gz: 5b18d980cf4ee656803dfafb82213fb9a1edb91ba283228a375d963cc8ea5cc5fc93a790472f6d10d37731df6be86b36595fda34f07bf7521564ea8c046f9338
@@ -0,0 +1,23 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /_yardoc/
4
+ /coverage/
5
+ /doc/
6
+ /pkg/
7
+ /spec/reports/
8
+ /tmp/
9
+ /Gemfile.lock
10
+ /.ruby-gemset
11
+ /.ruby-version
12
+
13
+ # rspec failure tracking
14
+ .rspec_status
15
+
16
+ # sneakers pid file
17
+ sneakers.pid
18
+
19
+ # log files
20
+ spec/apps/log/*
21
+
22
+ # temp files
23
+ spec/apps/tmp/*
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --color
2
+ --require spec_helper
@@ -0,0 +1,35 @@
1
+ Layout/LineLength:
2
+ Max: 140
3
+ Exclude:
4
+ - spec/**/*
5
+
6
+ Metrics/ClassLength:
7
+ Enabled: false
8
+
9
+ Metrics/BlockLength:
10
+ Exclude:
11
+ - advanced-sneakers-activejob.gemspec
12
+ - spec/**/*
13
+
14
+ Metrics/MethodLength:
15
+ Exclude:
16
+ - spec/**/*
17
+
18
+ Security/MarshalLoad:
19
+ Exclude:
20
+ - spec/**/*
21
+
22
+ Security/Eval:
23
+ Exclude:
24
+ - spec/apps/**/*
25
+
26
+ Metrics/AbcSize:
27
+ Exclude:
28
+ - spec/**/*
29
+
30
+ Lint/SuppressedException:
31
+ Exclude:
32
+ - spec/**/*
33
+
34
+ Style/Documentation:
35
+ Enabled: false
@@ -0,0 +1,21 @@
1
+ ---
2
+ dist: xenial
3
+ language: ruby
4
+ cache: bundler
5
+ rvm:
6
+ - 2.4.9
7
+ - 2.5.7
8
+ - 2.6.5
9
+ env:
10
+ - RAILS_VERSION="~> 4.2.11"
11
+ - RAILS_VERSION="~> 5.2.4"
12
+ - RAILS_VERSION="~> 6.0.2"
13
+ jobs:
14
+ exclude:
15
+ - rvm: 2.4.9
16
+ env: RAILS_VERSION="~> 6.0.2"
17
+
18
+ before_install: gem install bundler -v 1.17.3
19
+ before_script:
20
+ - ".ci/install_rabbitmq"
21
+ bundler_args: --jobs 3 --retry 3
@@ -0,0 +1 @@
1
+ ## Original Release: 0.1.0
data/Gemfile ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ git_source(:github) { |repo_name| "https://github.com/#{repo_name}" }
6
+
7
+ # Specify your gem's dependencies in advanced-sneakers-activejob.gemspec
8
+ gemspec
9
+
10
+ gem 'rails', ENV.fetch('RAILS_VERSION', '~> 4.2.11')
11
+ gem 'sneakers', ENV.fetch('SNEAKERS_VERSION', '~> 2.11')
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2020 Rustam Sharshenov
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.
@@ -0,0 +1,156 @@
1
+ # `:advanced_sneakers` adapter for ActiveJob [![Build Status](https://travis-ci.com/veeqo/advanced-sneakers-activejob.svg?branch=master)](https://travis-ci.com/veeqo/advanced-sneakers-activejob)
2
+
3
+ Drop-in replacement for `:sneakers` adapter of ActiveJob. Extra features:
4
+
5
+ 1. Tries to [handle unrouted messages](#unrouted-messages)
6
+ 2. Respects `queue_as` of ActiveJob and defines consumer class per RabbitMQ queue
7
+ 3. Supports [custom routing keys](#custom-routing-keys)
8
+ 4. Allows to run ActiveJob consumers [separately](#how-to-separate-activejob-consumers) from native Sneakers consumers
9
+ 5. Support for [`delayed jobs`](https://edgeguides.rubyonrails.org/active_job_basics.html#enqueue-the-job) `GuestsCleanupJob.set(wait: 1.week).perform_later(guest)`
10
+ 6. [Exponential backoff\*](#exponential-backoff)
11
+ 7. Exposes [`#delivery_info` & `#headers`](#amqp-metadata) AMQP metadata to job
12
+
13
+ ## Installation
14
+
15
+ Add this line to your application's Gemfile:
16
+
17
+ ```ruby
18
+ gem 'advanced-sneakers-activejob'
19
+ ```
20
+
21
+ And then execute:
22
+
23
+ $ bundle
24
+
25
+ Or install it yourself as:
26
+
27
+ $ gem install advanced-sneakers-activejob
28
+
29
+ ## Usage
30
+
31
+ [Configure ActiveJob adapter](https://edgeguides.rubyonrails.org/active_job_basics.html#setting-the-backend)
32
+ ```ruby
33
+ config.active_job.queue_adapter = :advanced_sneakers
34
+ ```
35
+
36
+ Run worker
37
+ ```sh
38
+ rake sneakers:active_job
39
+ ```
40
+
41
+ ## Unrouted messages
42
+
43
+ If message is published before routing has been configured (e.g. by consumer), it might be lost. To mitigate this problem the adapter uses [:mandatory](http://rubybunny.info/articles/exchanges.html#publishing_messages_as_mandatory) option for publishing messages. RabbitMQ returns unrouted messages back and the publisher is able to handle them:
44
+
45
+ 1. Create queue
46
+ 2. Create binding
47
+ 3. Re-publish message
48
+
49
+ There is a setting `handle_unrouted_messages` in [configuration](#configuration) to disable this behavior. If it is disabled, publisher will only log unrouted messages.
50
+
51
+ Take into accout that **this process is asynchronous**. It means that in case of network failures or process exit unrouted messages could be lost. The adapter tries to postpone application exit up to 30 seconds in case if there are unrouted messages, but it does not provide any guarantees.
52
+
53
+ **Delayed messages are not handled!** If job is delayed `GuestsCleanupJob.set(wait: 1.week).perform_later(guest)` and there is no proper routing defined at the moment of job execution, it would be lost.
54
+
55
+ ## Custom routing keys
56
+
57
+ Advanced sneakers adapter supports customizable [routing keys](https://www.rabbitmq.com/tutorials/tutorial-four-ruby.html).
58
+
59
+ ```ruby
60
+ class MyJob < ActiveJob::Base
61
+
62
+ queue_as :some_name
63
+
64
+ def perform(params)
65
+ # ProcessData.new(params).call
66
+ end
67
+
68
+ def routing_key
69
+ # we have instance of job here (including #arguments)
70
+ 'my.custom.routing.key'
71
+ end
72
+ end
73
+ ```
74
+
75
+ Take into accout that **custom routing key is used for publishing only**.
76
+
77
+ ## How to separate ActiveJob consumers
78
+
79
+ Sneakers comes with `rake sneakers:run` task, which would run all consumers (including ActiveJob ones). If you need to run native sneakers consumers apart from ActiveJob consumers:
80
+ 1. Set `activejob_workers_strategy` to `:exclude` in [configuration](#configuration)
81
+ 2. Run `rake sneakers:run` task to run native Sneakers consumers
82
+ 3. Run `rake sneakers:active_job` task to run ActiveJob consumers
83
+
84
+ Tip: if you want to see how consumers are grouped, exec `Sneakers::Worker::Classes` in rails console.
85
+
86
+ ## Exponential backoff\*
87
+
88
+ The adapter enforces `AdvancedSneakersActiveJob::Handler` for ActiveJob consumers. This handler applies [exponential backoff](https://en.wikipedia.org/wiki/Exponential_backoff) if failure is not handled by ActiveJob [`rescue_from`/`retry_on`/`discard_on`](https://edgeguides.rubyonrails.org/active_job_basics.html#retrying-or-discarding-failed-jobs).
89
+ Error name is tracked in `x-last-error-name`, error full message is tracked in `x-last-error-details` gzipped & encoded by Base64. To decode error details:
90
+
91
+ ```ruby
92
+ ActiveSupport::Gzip.decompress(Base64.decode64(data_from_header))
93
+ ```
94
+
95
+ \* For RabbitMQ queues amount optimization exponential backoff is not calculated by formula, but predifined. You can customize `retry_delay_proc` in [configuration](#configuration)
96
+
97
+ ## AMQP metadata
98
+
99
+ Each message in AMQP comes with `delivery_info` and `headers`. `:advanced_sneakers` adapter provides them on job level.
100
+
101
+ ```ruby
102
+ class SomeComplexJob < ActiveJob::Base
103
+ before :perform do |job|
104
+ # metadata is available in callbacks
105
+ logger.debug({delivery_info: job.delivery_info, headers: job.headers})
106
+ end
107
+
108
+ def perform(msg)
109
+ # metadata is available here as well
110
+ logger.debug({delivery_info: delivery_info, headers: headers})
111
+ end
112
+ end
113
+ ```
114
+
115
+ ## Configuration
116
+
117
+ ```ruby
118
+ AdvancedSneakersActiveJob.configure do |config|
119
+ # Should AdvancedSneakersActiveJob try to handle unrouted messages?
120
+ # There are still no guarantees that unrouted message is not lost in case of network failure or process exit.
121
+ # Delayed unrouted messages are not handled.
122
+ config.handle_unrouted_messages = true
123
+
124
+ # Should Sneakers build-in runner (e.g. `rake sneakers:run`) run ActiveJob consumers?
125
+ # :include - yes
126
+ # :exclude - no
127
+ # :only - Sneakers runner will run _only_ ActiveJob consumers
128
+ #
129
+ # This setting might be helpful if you want to run ActiveJob consumers apart from native Sneakers consumers.
130
+ # In that case set strategy to :exclude and use `rake sneakers:run` for native and `rake sneakers:active_job` for ActiveJob consumers
131
+ config.activejob_workers_strategy = :include
132
+
133
+ # All delayed messages delays are rounded to seconds.
134
+ config.delay_proc = ->(timestamp) { (timestamp - Time.now.to_f).round } } # integer result is expected
135
+
136
+ # Delayed queues can be filtered by this prefix (e.g. delayed:60 - queue for messages with 1 minute delay)
137
+ config.delayed_queue_prefix = 'delayed'
138
+
139
+ # Custom sneakers configuration for ActiveJob publisher & runner
140
+ config.sneakers = {
141
+ exchange: 'activejob',
142
+ handler: AdvancedSneakersActiveJob::Handler
143
+ }
144
+
145
+ # Define custom delay for retries, but remember - each unique delay leads to new queue on RabbitMQ side
146
+ config.retry_delay_proc = ->(count) { AdvancedSneakersActiveJob::EXPONENTIAL_BACKOFF[count] }
147
+ end
148
+ ```
149
+
150
+ ## Contributing
151
+
152
+ Bug reports and pull requests are welcome on GitHub at https://github.com/veeqo/advanced-sneakers-activejob.
153
+
154
+ ## License
155
+
156
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rspec/core/rake_task'
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ lib = File.expand_path('lib', __dir__)
4
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
5
+ require 'advanced_sneakers_activejob/version'
6
+
7
+ Gem::Specification.new do |spec|
8
+ spec.name = 'advanced-sneakers-activejob'
9
+ spec.version = AdvancedSneakersActiveJob::VERSION
10
+ spec.authors = ['Rustam Sharshenov', 'Vlad Bokov']
11
+ spec.email = ['rustam@sharshenov.com', 'vlad@lunatic.cat']
12
+
13
+ spec.summary = 'Advanced Sneakers adapter for ActiveJob'
14
+ spec.description = 'Advanced Sneakers adapter for ActiveJob'
15
+ spec.homepage = 'https://github.com/veeqo/advanced-sneakers-activejob'
16
+ spec.license = 'MIT'
17
+
18
+ if spec.respond_to?(:metadata)
19
+ spec.metadata['homepage_uri'] = spec.homepage
20
+ spec.metadata['source_code_uri'] = spec.homepage
21
+ spec.metadata['changelog_uri'] = 'https://github.com/veeqo/advanced-sneakers-activejob/blob/master/CHANGELOG.md'
22
+ end
23
+
24
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
25
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(spec|\.ci)/}) }
26
+ end
27
+ spec.require_paths = ['lib']
28
+
29
+ spec.add_dependency 'activejob', '>= 4.2'
30
+ spec.add_dependency 'sneakers', '~> 2.7'
31
+
32
+ spec.add_development_dependency 'bundler'
33
+ spec.add_development_dependency 'pry-byebug'
34
+ spec.add_development_dependency 'rabbitmq_http_api_client', '~> 1.13'
35
+ spec.add_development_dependency 'rails', '>= 4.2'
36
+ spec.add_development_dependency 'rake'
37
+ spec.add_development_dependency 'rspec'
38
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveJob
4
+ module QueueAdapters
5
+ # == Active Job advanced Sneakers adapter
6
+ #
7
+ # A high-performance RabbitMQ background processing framework for Ruby.
8
+ # Sneakers is being used in production for both I/O and CPU intensive
9
+ # workloads, and have achieved the goals of high-performance and
10
+ # 0-maintenance, as designed.
11
+ #
12
+ # Read more about Sneakers {here}[https://github.com/jondot/sneakers].
13
+ #
14
+ # To use the advanced Sneakers adapter set the queue_adapter config to +:advanced_sneakers+.
15
+ #
16
+ # Rails.application.config.active_job.queue_adapter = :advanced_sneakers
17
+ class AdvancedSneakersAdapter
18
+ @monitor = Monitor.new
19
+
20
+ class << self
21
+ def enqueue(job) #:nodoc:
22
+ AdvancedSneakersActiveJob.publisher.publish(*publish_params(job))
23
+ end
24
+
25
+ def enqueue_at(job, timestamp) #:nodoc:
26
+ delay = AdvancedSneakersActiveJob.config.delay_proc.call(timestamp).to_i
27
+
28
+ if delay.positive?
29
+ AdvancedSneakersActiveJob.publisher.publish_delayed(*publish_params(job).tap { |params| params.last[:delay] = delay })
30
+ else
31
+ enqueue(job)
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ def publish_params(job)
38
+ @monitor.synchronize do
39
+ [
40
+ Sneakers::ContentType.serialize(job.serialize, AdvancedSneakersActiveJob::CONTENT_TYPE),
41
+ { routing_key: routing_key(job) }
42
+ ]
43
+ end
44
+ end
45
+
46
+ def routing_key(job)
47
+ queue_name = job.queue_name.respond_to?(:call) ? job.queue_name.call : job.queue_name
48
+ job.respond_to?(:routing_key) ? job.routing_key : queue_name
49
+ end
50
+ end
51
+
52
+ delegate :enqueue, :enqueue_at, to: :'ActiveJob::QueueAdapters::AdvancedSneakersAdapter' # compatibility with Rails 5+
53
+
54
+ class JobWrapper #:nodoc:
55
+ def work_with_params(msg, delivery_info, headers)
56
+ # compatibility with :sneakers adapter
57
+ msg = ActiveSupport::JSON.decode(msg) unless headers[:content_type] == AdvancedSneakersActiveJob::CONTENT_TYPE
58
+
59
+ msg['delivery_info'] = delivery_info
60
+ msg['headers'] = headers
61
+ Base.execute msg
62
+ ack!
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'advanced_sneakers_activejob'
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support'
4
+ require 'active_support/core_ext'
5
+
6
+ require 'sneakers'
7
+ require 'advanced_sneakers_activejob/workers_registry'
8
+ Sneakers::Worker.send(:remove_const, :Classes)
9
+ Sneakers::Worker::Classes = AdvancedSneakersActiveJob::WorkersRegistry.new
10
+
11
+ require 'advanced_sneakers_activejob/version'
12
+ require 'advanced_sneakers_activejob/content_type'
13
+ require 'advanced_sneakers_activejob/exponential_backoff'
14
+ require 'advanced_sneakers_activejob/handler'
15
+ require 'advanced_sneakers_activejob/configuration'
16
+ require 'advanced_sneakers_activejob/errors'
17
+ require 'advanced_sneakers_activejob/publisher'
18
+ require 'advanced_sneakers_activejob/active_job_patch'
19
+ require 'advanced_sneakers_activejob/railtie' if defined?(::Rails::Railtie)
20
+ require 'active_job/queue_adapters/advanced_sneakers_adapter'
21
+
22
+ # Advanced Sneakers adapter for ActiveJob
23
+ module AdvancedSneakersActiveJob
24
+ class << self
25
+ def config
26
+ @config ||= Configuration.new
27
+ end
28
+
29
+ def configure
30
+ yield config
31
+ end
32
+
33
+ def define_consumer(queue_name:)
34
+ @consumers ||= {}
35
+
36
+ @consumers[queue_name] ||= begin
37
+ klass = Class.new(ActiveJob::QueueAdapters::AdvancedSneakersAdapter::JobWrapper)
38
+ klass.include Sneakers::Worker
39
+ const_set([queue_name, 'queue_consumer'].join('_').classify, klass)
40
+ klass.from_queue(queue_name, AdvancedSneakersActiveJob.config.sneakers)
41
+ end
42
+ end
43
+
44
+ def publisher
45
+ @publisher ||= AdvancedSneakersActiveJob::Publisher.new
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AdvancedSneakersActiveJob
4
+ module ActiveJobPatch
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ # AMQP message contains metadata which might be helpful for consumer (e.g. job.delivery_info.routing_key)
9
+ attr_accessor :delivery_info, :headers
10
+ end
11
+
12
+ module ClassMethods
13
+ def deserialize(job_data)
14
+ super(job_data).tap do |job|
15
+ job.delivery_info = job_data['delivery_info']
16
+ job.headers = job_data['headers']
17
+ end
18
+ end
19
+
20
+ def queue_as(*args)
21
+ super(*args)
22
+ define_consumer
23
+ end
24
+
25
+ private
26
+
27
+ def define_consumer
28
+ name = queue_name.respond_to?(:call) ? queue_name.call : queue_name
29
+
30
+ AdvancedSneakersActiveJob.define_consumer(queue_name: name)
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AdvancedSneakersActiveJob
4
+ # Advanced Sneakers adapter allows to patch Sneakers with custom configuration.
5
+ # It is useful when already have Sneakers workers running and you want to run ActiveJob Sneakers process with another options.
6
+ class Configuration
7
+ include ActiveSupport::Configurable
8
+
9
+ DEFAULT_SNEAKERS_CONFIG = {
10
+ exchange: 'activejob',
11
+ handler: AdvancedSneakersActiveJob::Handler
12
+ }.freeze
13
+
14
+ config_accessor(:handle_unrouted_messages) { true } # create queue/binding and re-publish if message is unrouted
15
+ config_accessor(:activejob_workers_strategy) { :include } # [:include, :exclude, :only]
16
+ config_accessor(:delay_proc) { ->(timestamp) { (timestamp - Time.now.to_f).round } } # seconds
17
+ config_accessor(:delayed_queue_prefix) { 'delayed' }
18
+ config_accessor(:retry_delay_proc) { ->(count) { AdvancedSneakersActiveJob::EXPONENTIAL_BACKOFF[count] } } # seconds
19
+
20
+ def sneakers
21
+ custom_config = DEFAULT_SNEAKERS_CONFIG.deep_merge(config.sneakers || {})
22
+
23
+ if custom_config[:amqp].present? & custom_config[:vhost].nil?
24
+ custom_config[:vhost] = AMQ::Settings.parse_amqp_url(custom_config[:amqp]).fetch(:vhost, '/')
25
+ end
26
+
27
+ Sneakers::CONFIG.to_hash.deep_merge(custom_config)
28
+ end
29
+
30
+ def sneakers=(custom)
31
+ config.sneakers = custom
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AdvancedSneakersActiveJob
4
+ CONTENT_TYPE = 'application/vnd.activejob+json'
5
+ end
6
+
7
+ Sneakers::ContentType.register(
8
+ content_type: AdvancedSneakersActiveJob::CONTENT_TYPE,
9
+ deserializer: ->(payload) { ActiveSupport::JSON.decode(payload) },
10
+ serializer: ->(payload) { ActiveSupport::JSON.encode(payload) }
11
+ )
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AdvancedSneakersActiveJob
4
+ class PublishError < StandardError; end
5
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AdvancedSneakersActiveJob
4
+ # Calculating exponential backoff by formulas with randomization leads to numerous RabbitMQ queues.
5
+ EXPONENTIAL_BACKOFF = {
6
+ 1 => 3, # 3 seconds
7
+ 2 => 30, # 30 seconds
8
+ 3 => 90, # 1.5 minutes
9
+ 4 => 240, # 4 minutes
10
+ 5 => 600, # 10 minutes
11
+ 6 => 1200, # 20 minutes
12
+ 7 => 2400, # 40 minutes
13
+ 8 => 3600, # 1 hour
14
+ 9 => 7200, # 2 hours
15
+ 10 => 10_800, # 3 hours
16
+ 11 => 14_400, # 4 hours
17
+ 12 => 21_600, # 6 hours
18
+ 13 => 28_800, # 8 hours
19
+ 14 => 36_000, # 10 hours
20
+ 15 => 50_400, # 14 hours
21
+ 16 => 64_800, # 18 hours
22
+ 17 => 86_400 # 24 hours
23
+ }.tap { |h| h.default = 86_400 }.freeze
24
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AdvancedSneakersActiveJob
4
+ # Handler puts error details to message header and reenqueues job with delay
5
+ class Handler < Sneakers::Handlers::Oneshot
6
+ def error(delivery_info, properties, message, error)
7
+ params = properties.to_h
8
+ params[:headers] = patch_headers(params[:headers], delivery_info, error)
9
+ params[:routing_key] = delivery_info.routing_key
10
+ params[:delay] = calculate_delay(params[:headers], delivery_info)
11
+
12
+ AdvancedSneakersActiveJob.publisher.publish_delayed(message, params)
13
+
14
+ acknowledge(delivery_info, properties, message)
15
+ end
16
+
17
+ private
18
+
19
+ def patch_headers(headers, delivery_info, error)
20
+ queue = queue_name(delivery_info)
21
+ exchange = delivery_info.exchange
22
+ routing_key = delivery_info.routing_key
23
+
24
+ track_error_in_headers(headers, error)
25
+ track_death_in_headers(headers, queue, exchange, routing_key)
26
+
27
+ headers
28
+ end
29
+
30
+ # Headers are patched to mimic behavior of "nack" and DLX
31
+ def track_death_in_headers(headers, queue, exchange, routing_key)
32
+ headers['x-first-death-exchange'] ||= exchange
33
+ headers['x-first-death-queue'] ||= queue
34
+ headers['x-first-death-reason'] ||= 'rejected'
35
+ headers['x-death'] ||= []
36
+
37
+ if (death = death_header(headers, queue))
38
+ death['count'] += 1
39
+ else
40
+ headers['x-death'] << build_death_row(queue, exchange, routing_key)
41
+ end
42
+ end
43
+
44
+ def build_death_row(queue, exchange, routing_key)
45
+ {
46
+ 'count' => 1,
47
+ 'reason' => 'rejected',
48
+ 'queue' => queue,
49
+ 'time' => Time.now,
50
+ 'exchange' => exchange,
51
+ 'routing-keys' => [routing_key]
52
+ }
53
+ end
54
+
55
+ def track_error_in_headers(headers, error)
56
+ details = if error.respond_to?(:full_message) # ruby 2.5+
57
+ error.full_message
58
+ else
59
+ ([error.message] + error.backtrace).join("\n")
60
+ end
61
+
62
+ headers['x-last-error-name'] = error.class.name
63
+ headers['x-last-error-details'] = Base64.encode64(ActiveSupport::Gzip.compress(details))
64
+ end
65
+
66
+ def calculate_delay(headers, delivery_info)
67
+ death_count = death_header(headers, queue_name(delivery_info)).fetch('count')
68
+
69
+ AdvancedSneakersActiveJob.config.retry_delay_proc.call(death_count)
70
+ end
71
+
72
+ def queue_name(delivery_info)
73
+ delivery_info.consumer.queue.name
74
+ end
75
+
76
+ def death_header(headers, queue_name)
77
+ headers.fetch('x-death').detect { |death| death.fetch('queue') == queue_name }
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,210 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AdvancedSneakersActiveJob
4
+ # Based on Sneakers::Publisher, but refactored to utilize :mandatory option to handle unrouted messages
5
+ # http://rubybunny.info/articles/exchanges.html#publishing_messages_as_mandatory
6
+ class Publisher
7
+ WAIT_FOR_UNROUTED_MESSAGES_AT_EXIT_TIMEOUT = 30
8
+
9
+ delegate :sneakers, :handle_unrouted_messages, :delayed_queue_prefix,
10
+ to: :'AdvancedSneakersActiveJob.config', prefix: :config
11
+
12
+ delegate :logger, to: :'ActiveJob::Base'
13
+
14
+ attr_reader :publish_channel, :republish_channel,
15
+ :publish_exchange, :republish_exchange,
16
+ :publish_delayed_exchange, :republish_delayed_exchange
17
+
18
+ def initialize
19
+ @mutex = Mutex.new
20
+ at_exit { wait_for_unrouted_messages_processing(timeout: WAIT_FOR_UNROUTED_MESSAGES_AT_EXIT_TIMEOUT) }
21
+ end
22
+
23
+ def publish(message, routing_key:, headers: {}, **properties)
24
+ ensure_connection!
25
+
26
+ logger.debug "Publishing <#{message}> to [#{publish_exchange.name}] with routing_key [#{routing_key}]"
27
+
28
+ params = properties.deep_symbolize_keys.merge(
29
+ routing_key: routing_key,
30
+ mandatory: true,
31
+ content_type: AdvancedSneakersActiveJob::CONTENT_TYPE,
32
+ headers: headers
33
+ )
34
+
35
+ publish_exchange.publish(message, params)
36
+ end
37
+
38
+ def publish_delayed(message, routing_key:, delay:, headers: {}, **properties)
39
+ ensure_connection!
40
+
41
+ logger.debug "Publishing <#{message}> to [#{publish_delayed_exchange.name}] with routing_key [#{routing_key}] and delay [#{delay}]"
42
+
43
+ params = properties.deep_symbolize_keys.merge(
44
+ routing_key: routing_key,
45
+ mandatory: true,
46
+ content_type: AdvancedSneakersActiveJob::CONTENT_TYPE,
47
+ headers: headers.deep_symbolize_keys.merge(delay: delay.to_i) # do not use x- prefix because headers exchanges ignore such headers
48
+ )
49
+
50
+ publish_delayed_exchange.publish(message, params)
51
+ end
52
+
53
+ private
54
+
55
+ def ensure_connection!
56
+ @mutex.synchronize do
57
+ unless connected?
58
+ start_connections!
59
+ create_channels!
60
+ configure_exchanges!
61
+ end
62
+ end
63
+ end
64
+
65
+ def start_connections!
66
+ @publish_connection ||= create_bunny_connection
67
+ @publish_connection.start
68
+
69
+ @republish_connection ||= create_bunny_connection
70
+ @republish_connection.start
71
+ end
72
+
73
+ def create_channels!
74
+ @publish_channel = @publish_connection.create_channel
75
+ @republish_channel = @republish_connection.create_channel
76
+ end
77
+
78
+ def configure_exchanges!
79
+ @publish_exchange = build_exchange(@publish_channel)
80
+ @publish_exchange.on_return { |*attrs| handle_unrouted_messages(*attrs) }
81
+
82
+ @publish_delayed_exchange = build_delayed_exchange(@publish_channel)
83
+ @publish_delayed_exchange.on_return { |*attrs| handle_unrouted_delayed_messages(*attrs) }
84
+
85
+ @republish_exchange = build_exchange(republish_channel)
86
+ @republish_delayed_exchange = build_delayed_exchange(republish_channel)
87
+ end
88
+
89
+ def connected?
90
+ @publish_connection&.connected? &&
91
+ @republish_connection&.connected? &&
92
+ @publish_channel &&
93
+ @republish_channel
94
+ end
95
+
96
+ # Returned messages are processed asynchronously and there is a probability for messages loses on program exit or network failure.
97
+ # Second connection is required because `on_return` is called within a frameset of amqp connection.
98
+ # Any interaction within the connection (even by another channel) can lead to connection error.
99
+ # https://github.com/ruby-amqp/bunny/blob/7fb05abf36637557f75a69790be78f9cc1cea807/lib/bunny/session.rb#L683
100
+ def handle_unrouted_messages(return_info, properties, message)
101
+ @unrouted_message = true
102
+
103
+ params = { message: message, return_info: return_info, properties: properties }
104
+
105
+ raise(PublishError, params) if return_info.reply_code != 312 # NO_ROUTE
106
+
107
+ if config_handle_unrouted_messages
108
+ setup_routing_and_republish_message(params)
109
+ else
110
+ logger.warn("Message is not routed! #{params}")
111
+ end
112
+
113
+ @unrouted_message = false
114
+ end
115
+
116
+ def handle_unrouted_delayed_messages(return_info, properties, message)
117
+ @unrouted_delayed_message = true
118
+
119
+ params = { message: message, return_info: return_info, properties: properties }
120
+
121
+ raise(PublishError, params) if return_info.reply_code != 312 # NO_ROUTE
122
+
123
+ setup_routing_and_republish_delayed_message(params)
124
+
125
+ @unrouted_delayed_message = false
126
+ end
127
+
128
+ # TODO: introduce more reliable way to wait for handling of unrouted messages at exit
129
+ def wait_for_unrouted_messages_processing(timeout:)
130
+ sleep(0.05) # gives publish_exchange some time to receive retuned message
131
+
132
+ return unless @unrouted_message || @unrouted_delayed_message
133
+
134
+ logger.warn("Waiting up to #{timeout} seconds for unrouted messages handling")
135
+
136
+ Timeout.timeout(timeout) { sleep 0.01 while @unrouted_message || @unrouted_delayed_message }
137
+ rescue Timeout::Error
138
+ logger.warn('Some unrouted messages are lost on process exit!')
139
+ end
140
+
141
+ def setup_routing_and_republish_message(message:, return_info:, properties:)
142
+ logger.debug("Performing queue/binding setup & re-publish for unrouted message. #{{ message: message, return_info: return_info }}")
143
+
144
+ routing_key = return_info.routing_key
145
+
146
+ create_queue_and_binding(queue_name: deserialize(message).fetch('queue_name'), routing_key: routing_key)
147
+
148
+ logger.debug "Re-publishing <#{message}> to [#{republish_exchange.name}] with routing_key [#{routing_key}]"
149
+ republish_exchange.publish(message, properties.to_h.merge(routing_key: routing_key))
150
+ end
151
+
152
+ def create_queue_and_binding(queue_name:, routing_key:)
153
+ logger.debug "Creating queue [#{queue_name}] and binding with routing_key [#{routing_key}] to [#{republish_exchange.name}]"
154
+ republish_channel.queue(queue_name, config_sneakers[:queue_options]).tap do |queue|
155
+ queue.bind(republish_exchange, routing_key: routing_key)
156
+ republish_channel.deregister_queue(queue) # we are not going to work with this queue in this channel
157
+ end
158
+ end
159
+
160
+ def setup_routing_and_republish_delayed_message(message:, return_info:, properties:)
161
+ delay = properties.headers.fetch('delay').to_i
162
+ queue_name = delayed_queue_name(delay: delay)
163
+
164
+ logger.debug "Creating delayed queue [#{queue_name}]"
165
+
166
+ create_delayed_queue_and_binding(queue_name: queue_name, delay: delay)
167
+
168
+ republish_delayed_exchange.publish message, properties.to_h.merge(routing_key: return_info.routing_key)
169
+ end
170
+
171
+ def delayed_queue_name(delay:)
172
+ [
173
+ config_delayed_queue_prefix,
174
+ delay
175
+ ].join(':')
176
+ end
177
+
178
+ def create_delayed_queue_and_binding(queue_name:, delay:)
179
+ queue_arguments = {
180
+ 'x-queue-mode' => 'lazy', # tell RabbitMQ not to use RAM for this queue as it won't be consumed
181
+ 'x-message-ttl' => delay * 1000, # make messages die after requested time
182
+ 'x-dead-letter-exchange' => republish_exchange.name # died messages go to original exchange and then routed to consumers
183
+ }
184
+
185
+ republish_channel.queue(queue_name, durable: true, arguments: queue_arguments).tap do |queue|
186
+ queue.bind(republish_delayed_exchange, arguments: { delay: delay })
187
+ republish_channel.deregister_queue(queue) # we are not going to work with this queue in this channel
188
+ end
189
+ end
190
+
191
+ def build_exchange(channel)
192
+ channel.exchange(config_sneakers[:exchange], config_sneakers[:exchange_options])
193
+ end
194
+
195
+ def build_delayed_exchange(channel)
196
+ channel.exchange([config_sneakers[:exchange], 'delayed'].join('-'), type: 'headers', durable: true)
197
+ end
198
+
199
+ def create_bunny_connection
200
+ Bunny.new config_sneakers[:amqp],
201
+ vhost: config_sneakers[:vhost],
202
+ heartbeat: config_sneakers[:heartbeat],
203
+ properties: config_sneakers.fetch(:properties, {})
204
+ end
205
+
206
+ def deserialize(message)
207
+ Sneakers::ContentType.deserialize(message, AdvancedSneakersActiveJob::CONTENT_TYPE)
208
+ end
209
+ end
210
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AdvancedSneakersActiveJob
4
+ # Rails integration
5
+ class Railtie < ::Rails::Railtie
6
+ initializer 'advanced_sneakers_activejob.discover_mailer_job' do
7
+ ActiveSupport.on_load(:action_mailer) do
8
+ require 'action_mailer/delivery_job' # Enforce definition of ActionMailer::DeliveryJob::Consumer
9
+ end
10
+ end
11
+
12
+ initializer 'advanced_sneakers_activejob.discover_default_job' do
13
+ ActiveSupport.on_load(:active_job) do
14
+ ActiveJob::Base.include AdvancedSneakersActiveJob::ActiveJobPatch
15
+ ActiveJob::Base.queue_as # Enforce definition of ActiveJob::Base::Consumer (default queue)
16
+ end
17
+ end
18
+
19
+ rake_tasks do
20
+ require 'advanced_sneakers_activejob/tasks'
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'sneakers/tasks'
4
+
5
+ task :environment
6
+
7
+ namespace :sneakers do
8
+ desc 'Start work for ActiveJob only (set $WORKERS=ActiveJobKlass1::Consumer,ActiveJobKlass2::Consumer)'
9
+ task :active_job do
10
+ Rake::Task['environment'].invoke
11
+
12
+ # Enforsing ActiveJob-only workers
13
+ AdvancedSneakersActiveJob.configure { |c| c.activejob_workers_strategy = :only }
14
+
15
+ Sneakers.configure(AdvancedSneakersActiveJob.config.sneakers)
16
+
17
+ Sneakers.logger.level = Logger::INFO # debug logs are too noizy because of bunny
18
+
19
+ Rake::Task['sneakers:run'].invoke
20
+ end
21
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AdvancedSneakersActiveJob
4
+ VERSION = '0.1.0'
5
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AdvancedSneakersActiveJob
4
+ # Sneakers uses Sneakers::Worker::Classes array to track all workers.
5
+ # WorkersRegistry mocks original array to track ActiveJob workers separately.
6
+ class WorkersRegistry
7
+ attr_reader :sneakers_workers, :activejob_workers
8
+
9
+ delegate :activejob_workers_strategy, to: :'AdvancedSneakersActiveJob.config'
10
+
11
+ delegate :empty?, to: :call
12
+
13
+ def initialize
14
+ @sneakers_workers = []
15
+ @activejob_workers = []
16
+ end
17
+
18
+ def <<(worker)
19
+ if worker <= ActiveJob::QueueAdapters::AdvancedSneakersAdapter::JobWrapper
20
+ activejob_workers << worker
21
+ else
22
+ sneakers_workers << worker
23
+ end
24
+ end
25
+
26
+ # Sneakers workergroup supports callable objects.
27
+ # https://github.com/jondot/sneakers/pull/210/files
28
+ # https://github.com/jondot/sneakers/blob/7a972d22a58de8a261a738d9a1e5fb51f9608ede/lib/sneakers/workergroup.rb#L28
29
+ def call
30
+ case activejob_workers_strategy
31
+ when :only then activejob_workers
32
+ when :exclude then sneakers_workers
33
+ when :include then sneakers_workers + activejob_workers
34
+ else
35
+ raise "Unknown activejob_workers_strategy '#{activejob_workers_strategy}'"
36
+ end
37
+ end
38
+
39
+ # For cleaner output on inspecting Sneakers::Worker::Classes in console.
40
+ def inspect
41
+ {
42
+ sneakers_workers: sneakers_workers,
43
+ activejob_workers: activejob_workers
44
+ }
45
+ end
46
+ end
47
+ end
metadata ADDED
@@ -0,0 +1,184 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: advanced-sneakers-activejob
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Rustam Sharshenov
8
+ - Vlad Bokov
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2020-03-19 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: activejob
16
+ requirement: !ruby/object:Gem::Requirement
17
+ requirements:
18
+ - - ">="
19
+ - !ruby/object:Gem::Version
20
+ version: '4.2'
21
+ type: :runtime
22
+ prerelease: false
23
+ version_requirements: !ruby/object:Gem::Requirement
24
+ requirements:
25
+ - - ">="
26
+ - !ruby/object:Gem::Version
27
+ version: '4.2'
28
+ - !ruby/object:Gem::Dependency
29
+ name: sneakers
30
+ requirement: !ruby/object:Gem::Requirement
31
+ requirements:
32
+ - - "~>"
33
+ - !ruby/object:Gem::Version
34
+ version: '2.7'
35
+ type: :runtime
36
+ prerelease: false
37
+ version_requirements: !ruby/object:Gem::Requirement
38
+ requirements:
39
+ - - "~>"
40
+ - !ruby/object:Gem::Version
41
+ version: '2.7'
42
+ - !ruby/object:Gem::Dependency
43
+ name: bundler
44
+ requirement: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '0'
49
+ type: :development
50
+ prerelease: false
51
+ version_requirements: !ruby/object:Gem::Requirement
52
+ requirements:
53
+ - - ">="
54
+ - !ruby/object:Gem::Version
55
+ version: '0'
56
+ - !ruby/object:Gem::Dependency
57
+ name: pry-byebug
58
+ requirement: !ruby/object:Gem::Requirement
59
+ requirements:
60
+ - - ">="
61
+ - !ruby/object:Gem::Version
62
+ version: '0'
63
+ type: :development
64
+ prerelease: false
65
+ version_requirements: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - ">="
68
+ - !ruby/object:Gem::Version
69
+ version: '0'
70
+ - !ruby/object:Gem::Dependency
71
+ name: rabbitmq_http_api_client
72
+ requirement: !ruby/object:Gem::Requirement
73
+ requirements:
74
+ - - "~>"
75
+ - !ruby/object:Gem::Version
76
+ version: '1.13'
77
+ type: :development
78
+ prerelease: false
79
+ version_requirements: !ruby/object:Gem::Requirement
80
+ requirements:
81
+ - - "~>"
82
+ - !ruby/object:Gem::Version
83
+ version: '1.13'
84
+ - !ruby/object:Gem::Dependency
85
+ name: rails
86
+ requirement: !ruby/object:Gem::Requirement
87
+ requirements:
88
+ - - ">="
89
+ - !ruby/object:Gem::Version
90
+ version: '4.2'
91
+ type: :development
92
+ prerelease: false
93
+ version_requirements: !ruby/object:Gem::Requirement
94
+ requirements:
95
+ - - ">="
96
+ - !ruby/object:Gem::Version
97
+ version: '4.2'
98
+ - !ruby/object:Gem::Dependency
99
+ name: rake
100
+ requirement: !ruby/object:Gem::Requirement
101
+ requirements:
102
+ - - ">="
103
+ - !ruby/object:Gem::Version
104
+ version: '0'
105
+ type: :development
106
+ prerelease: false
107
+ version_requirements: !ruby/object:Gem::Requirement
108
+ requirements:
109
+ - - ">="
110
+ - !ruby/object:Gem::Version
111
+ version: '0'
112
+ - !ruby/object:Gem::Dependency
113
+ name: rspec
114
+ requirement: !ruby/object:Gem::Requirement
115
+ requirements:
116
+ - - ">="
117
+ - !ruby/object:Gem::Version
118
+ version: '0'
119
+ type: :development
120
+ prerelease: false
121
+ version_requirements: !ruby/object:Gem::Requirement
122
+ requirements:
123
+ - - ">="
124
+ - !ruby/object:Gem::Version
125
+ version: '0'
126
+ description: Advanced Sneakers adapter for ActiveJob
127
+ email:
128
+ - rustam@sharshenov.com
129
+ - vlad@lunatic.cat
130
+ executables: []
131
+ extensions: []
132
+ extra_rdoc_files: []
133
+ files:
134
+ - ".gitignore"
135
+ - ".rspec"
136
+ - ".rubocop.yml"
137
+ - ".travis.yml"
138
+ - CHANGELOG.md
139
+ - Gemfile
140
+ - LICENSE.txt
141
+ - README.md
142
+ - Rakefile
143
+ - advanced-sneakers-activejob.gemspec
144
+ - lib/active_job/queue_adapters/advanced_sneakers_adapter.rb
145
+ - lib/advanced/sneakers/activejob.rb
146
+ - lib/advanced_sneakers_activejob.rb
147
+ - lib/advanced_sneakers_activejob/active_job_patch.rb
148
+ - lib/advanced_sneakers_activejob/configuration.rb
149
+ - lib/advanced_sneakers_activejob/content_type.rb
150
+ - lib/advanced_sneakers_activejob/errors.rb
151
+ - lib/advanced_sneakers_activejob/exponential_backoff.rb
152
+ - lib/advanced_sneakers_activejob/handler.rb
153
+ - lib/advanced_sneakers_activejob/publisher.rb
154
+ - lib/advanced_sneakers_activejob/railtie.rb
155
+ - lib/advanced_sneakers_activejob/tasks.rb
156
+ - lib/advanced_sneakers_activejob/version.rb
157
+ - lib/advanced_sneakers_activejob/workers_registry.rb
158
+ homepage: https://github.com/veeqo/advanced-sneakers-activejob
159
+ licenses:
160
+ - MIT
161
+ metadata:
162
+ homepage_uri: https://github.com/veeqo/advanced-sneakers-activejob
163
+ source_code_uri: https://github.com/veeqo/advanced-sneakers-activejob
164
+ changelog_uri: https://github.com/veeqo/advanced-sneakers-activejob/blob/master/CHANGELOG.md
165
+ post_install_message:
166
+ rdoc_options: []
167
+ require_paths:
168
+ - lib
169
+ required_ruby_version: !ruby/object:Gem::Requirement
170
+ requirements:
171
+ - - ">="
172
+ - !ruby/object:Gem::Version
173
+ version: '0'
174
+ required_rubygems_version: !ruby/object:Gem::Requirement
175
+ requirements:
176
+ - - ">="
177
+ - !ruby/object:Gem::Version
178
+ version: '0'
179
+ requirements: []
180
+ rubygems_version: 3.0.6
181
+ signing_key:
182
+ specification_version: 4
183
+ summary: Advanced Sneakers adapter for ActiveJob
184
+ test_files: []