leveret 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 78c17ba2d29034d05262a5c3454ddf1e423b6cdd
4
+ data.tar.gz: a5346c1ba41a7647f997b2599730c682bc0e885a
5
+ SHA512:
6
+ metadata.gz: 95b4f65d3fac2a06b6a2c06def7bd9408d35d1bf31aead3828030f8d001f5792fbe6c8c2c63e2d1116c3e983e323ec3a1e83bcc0677253805657696cfb9a9872
7
+ data.tar.gz: 6c46a540248cc82b981ec5e5ff869fa578e5cbefc6b27ae1c7daed891e6178c62031cd2fc374cfc3dc14901886fbcc8ab0d56f9222c9d8f02e933ba6e2303c5e
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --format documentation
2
+ --color
data/.travis.yml ADDED
@@ -0,0 +1,4 @@
1
+ language: ruby
2
+ rvm:
3
+ - 2.2.3
4
+ before_install: gem install bundler -v 1.10.6
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in leveret.gemspec
4
+ gemspec
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2016 Dan Wentworth
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.
data/README.md ADDED
@@ -0,0 +1,204 @@
1
+ # Leveret
2
+
3
+ Leveret is an easy to use RabbitMQ backed job runner.
4
+
5
+ It's designed specifically to execute long running jobs (multiple hours) while allowing the applicatioin to be
6
+ restarted with no adverse effects on the currently running jobs.
7
+
8
+ Leveret has been tested with Ruby 2.2.3+ and RabbitMQ 3.5.0+.
9
+
10
+ ## Installation
11
+
12
+ Add this line to your application's Gemfile:
13
+
14
+ ```ruby
15
+ gem 'leveret'
16
+ ```
17
+
18
+ And then execute:
19
+
20
+ $ bundle
21
+
22
+ Or install it yourself as:
23
+
24
+ $ gem install leveret
25
+
26
+ To use Leveret you need a running RabbitMQ installation. If you don't have it installed yet, you can do so via
27
+ [Homebrew](https://www.rabbitmq.com/install-homebrew.html) (MacOS), [APT](https://www.rabbitmq.com/install-debian.html)
28
+ (Debian/Ubuntu) or [RPM](https://www.rabbitmq.com/install-rpm.html) (Redhat/Fedora).
29
+
30
+ RabbitMQ version **3.5.0** or higher is recommended as this is the first version to support message priorities.
31
+
32
+ ## Usage
33
+
34
+ To create a job include `Leveret::Job` in a new class, and define a `perform` method that will do the work in
35
+ your job. Call `enqueue` on your new class with any parameters you want passed to the job at execution time.
36
+
37
+ ```ruby
38
+ class MyJob
39
+ include Leveret::Job
40
+
41
+ def perform
42
+ File.open('/tmp/leveret-test-file.txt', 'a+') do |f|
43
+ f.puts params[:test_text]
44
+ end
45
+
46
+ sleep 5 # Job takes a long time
47
+ end
48
+ end
49
+
50
+ MyJob.enqueue(test_text: "Hi there! Please write me to the test file.")
51
+ ```
52
+
53
+ Now start a worker to execute the job:
54
+
55
+ ```bash
56
+ bundle exec leveret_worker
57
+ ```
58
+
59
+ ### Queues
60
+
61
+ By default all are defined on a single standard queue (see Configuration for details). However, it's possible to use
62
+ multiple queues for different jobs. To do this set the `queue_name` in your job class. You'll also need to tell the
63
+ worker about your new queue when starting that.
64
+
65
+ ```ruby
66
+ class MyOtherQueueJob
67
+ include Leveret::Job
68
+
69
+ queue_name 'other'
70
+
71
+ def perform
72
+ # ...
73
+ end
74
+ end
75
+
76
+ MyOtherQueueJob.enqueue(test_text: "Hi there! Please write me to the test file.")
77
+ ```
78
+
79
+ If you don't always want to place the job on your other queue, you can specify the queue name when enqueuing it. Pass
80
+ the `queue_name` option when enqueuing the job.
81
+
82
+ ```ruby
83
+ MyJob.enqueue(test_text: "Hi there! Please write me to the test file.", queue_name: 'other')
84
+ ```
85
+
86
+ ### Priorities
87
+
88
+ Leveret supports 3 levels of job priority, `:low`, `:normal` and `:high`. To set the priority you can define it in your
89
+ job class, or specify it at enqueue time by passing the `priority` option.
90
+
91
+ ```ruby
92
+ class MyHighPriorityMyJob
93
+ include Leveret::Job
94
+
95
+ priority :high
96
+
97
+ def perform
98
+ # very important work...
99
+ end
100
+ end
101
+
102
+ MyHighPriorityJob.enqueue
103
+ ```
104
+
105
+ To specify priority at enqueue time:
106
+
107
+ ```ruby
108
+ MyJob.enqueue(test_text: "Hi there! Please write me to the test file.", priority: :high)
109
+ ```
110
+
111
+ ### Workers
112
+
113
+ To start a leveret worker, simply run the `leveret_worker` executable included in the gem. Started with no arguments it
114
+ will create a worker monitoring the default queue and process one job at a time.
115
+
116
+ Changing the queues that a worker monitors requires passing a comma separated list of queue names in the environment
117
+ variable `QUEUES`. The example below watches for jobs on the queues `standard` and `other`.
118
+
119
+ ```bash
120
+ bundle exec leveret_worker QUEUES=standard,other
121
+ ```
122
+
123
+ By default, workers will only process one job at a time. For each job that is executed, a child process is forked, and
124
+ the job run in the new process. When the job completes, the fork exits. We can process more jobs simultaniously simply
125
+ by allowing more forks to run. To increase this limit set the `PROCESSES` environment variable. There is no limit to
126
+ this variable in Leveret, but you should be aware of your own OS and resource limits.
127
+
128
+ ```bash
129
+ bundle exec leveret_worker PROCESSES=5
130
+ ```
131
+
132
+ ## Configuration
133
+
134
+ Configuration in Leveret is done via a configure block. In a Rails application it is recommended you place your
135
+ configuration in `config/initializers/leveret.rb`. Leveret comes configured with sane defaults for development, but you
136
+ may wish to change some for production use.
137
+
138
+ ```ruby
139
+ Leveret.configure do |config|
140
+ # Location of your RabbitMQ server
141
+ config.amqp = "amqp://guest:guest@localhost:5672/"
142
+ # Name of the exchange Levert will create on RabbitMQ
143
+ config.exchange_name = 'leveret_exch'
144
+ # Path to send log output to
145
+ config.log_file = STDOUT
146
+ # Verbosity of log output
147
+ config.log_level = Logger::DEBUG
148
+ # String that is prepended to all queues created in RabbitMQ
149
+ config.queue_name_prefix = 'leveret_queue'
150
+ # Name of the queue to use if none other is specified
151
+ config.default_queue_name = 'standard'
152
+ # A block that should be called every time a child fork is created to process a job
153
+ config.after_fork = proc {}
154
+ # A block that is called whenever an exception occurs in a job
155
+ config.error_handler = proc { |ex| ex }
156
+ # The default number of jobs to process simultaniously, this can be overridden by the PROCESSES
157
+ # environment variable when starting a worker
158
+ config.concurrent_fork_count = 1
159
+ end
160
+ ```
161
+
162
+ Most of these are pretty self-explanatory and can be left to their default values, however `after_fork` and
163
+ `error_handler` could use a little more explaining.
164
+
165
+ `after_fork` Is called immediately after a child process is forked to run a job. Any connections that need to be
166
+ reinitialized on fork should be done so here. For example, if you're using Rails you'll probably want to reconnect
167
+ to ActiveRecord here:
168
+
169
+ ```ruby
170
+ Leveret.configure do |config|
171
+ config.after_fork = proc do
172
+ ActiveRecord::Base.establish_connection
173
+ end
174
+ end
175
+ ```
176
+
177
+ `error_handler` is called whenever an exception is raised in your job. These exceptions are caught and logged, but not
178
+ raised afterwards. `error_handler` is your chance to decide what to do with these exceptions. You may wish to log them
179
+ using a service such as [Airbrake](https://airbrake.io/) or [Sentry](https://getsentry.com/welcome/). To configure an
180
+ error handler to log to Sentry the following would be necessary:
181
+
182
+ ```ruby
183
+ Leveret.configure do |config|
184
+ config.error_handler = proc do |exception|
185
+ Raven.capture_exception(exception, tags: {component: 'leveret'})
186
+ end
187
+ end
188
+ ```
189
+
190
+ ## Development
191
+
192
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
193
+
194
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
195
+
196
+ ## Contributing
197
+
198
+ Bug reports and pull requests are welcome on GitHub at https://github.com/darkphnx/leveret.
199
+
200
+
201
+ ## License
202
+
203
+ The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
204
+
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task :default => :spec
data/bin/console ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "leveret"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require "irb"
14
+ IRB.start
data/bin/setup ADDED
@@ -0,0 +1,7 @@
1
+ #!/bin/bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+
5
+ bundle install
6
+
7
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "leveret"
5
+
6
+ if File.exist?("./config/environment.rb")
7
+ require File.expand_path("./config/environment.rb")
8
+ end
9
+
10
+ queues = ENV["QUEUES"].to_s.split(',')
11
+ queues << Leveret.configuration.default_queue_name if queues.empty?
12
+
13
+ concurrent_fork_count = ENV["PROCESSES"] || Leveret.configuration.concurrent_fork_count
14
+
15
+ worker = Leveret::Worker.new(queues: queues, concurrent_fork_count: concurrent_fork_count)
16
+ worker.do_work
data/leveret.gemspec ADDED
@@ -0,0 +1,26 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'leveret/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "leveret"
8
+ spec.version = Leveret::VERSION
9
+ spec.authors = ["Dan Wentworth"]
10
+ spec.email = ["dan@atechmedia.com"]
11
+
12
+ spec.summary = "Simple RabbitMQ backed backround worker"
13
+ spec.homepage = "https://github.com/darkphnx/leveret"
14
+ spec.license = "MIT"
15
+
16
+ spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
17
+ spec.bindir = "exe"
18
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
19
+ spec.require_paths = ["lib"]
20
+
21
+ spec.add_dependency "bunny", '~> 2.3'
22
+ spec.add_dependency "json", '~> 1.8'
23
+ spec.add_development_dependency "bundler", "~> 1.10"
24
+ spec.add_development_dependency "rake", "~> 10.0"
25
+ spec.add_development_dependency "rspec"
26
+ end
@@ -0,0 +1,48 @@
1
+ module Leveret
2
+ # Contains everything needed to configure leveret for work. Sensible defaults are included
3
+ # and will be initialized with the class.
4
+ #
5
+ # @!attribute amqp
6
+ # @return [String] Location of your RabbitMQ server. Default: +"amqp://guest:guest@localhost:5672/"+
7
+ # @!attribute exchange_name
8
+ # @return [String] Name of the exchange for Leveret to publish messages to. Default: +"leveret_exch"+
9
+ # @!attribute queue_name_prefix
10
+ # @return [String] This value will be prefixed to all queues created on your RabbitMQ instance.
11
+ # Default: +"leveret_queue"+
12
+ # @!attribute log_file
13
+ # @return [String] The path where logfiles should be written to. Default: +STDOUT+
14
+ # @!attribute log_level
15
+ # @return [Integer] The log severity which should be output to the log. Default: +Logger::DEBUG+
16
+ # @!attribute default_queue_name
17
+ # @return [String] The name of the queue that will be use unless explicitly specified in a job. Default:
18
+ # +"standard"+
19
+ # @!attribute after_fork
20
+ # @return [Proc] A proc which will be executed in a child after forking to process a message. Default: +proc {}+
21
+ # @!attribute error_handler
22
+ # @return [Proc] A proc which will be called if a job raises an exception. Default: +proc {|ex| ex }+
23
+ # @!attribute concurrent_fork_count
24
+ # @return [Integer] The number of jobs that can be processes simultanously. Default: +1+
25
+ class Configuration
26
+ attr_accessor :amqp, :exchange_name, :queue_name_prefix, :log_file, :log_level, :default_queue_name, :after_fork,
27
+ :error_handler, :concurrent_fork_count
28
+
29
+ # Create a new instance of Configuration with a set of sane defaults.
30
+ def initialize
31
+ assign_defaults
32
+ end
33
+
34
+ private
35
+
36
+ def assign_defaults
37
+ self.amqp = "amqp://guest:guest@localhost:5672/"
38
+ self.exchange_name = 'leveret_exch'
39
+ self.log_file = STDOUT
40
+ self.log_level = Logger::DEBUG
41
+ self.queue_name_prefix = 'leveret_queue'
42
+ self.default_queue_name = 'standard'
43
+ self.after_fork = proc {}
44
+ self.error_handler = proc { |ex| ex }
45
+ self.concurrent_fork_count = 1
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,159 @@
1
+ module Leveret
2
+ # Include this module in your job to create a leveret compatible job.
3
+ # Once included, simply override #perform to do your jobs action.
4
+ #
5
+ # To set a different queue name call #queue_name in your class, to set
6
+ # the default priority call #priority in your class.
7
+ #
8
+ # To queue a job simply call #enqueue on the class with the parameters
9
+ # to be passed. These params will be serialized as JSON in the interim,
10
+ # so ensure that your params are json-safe.
11
+ #
12
+ # @example Job Class
13
+ # class MyJob
14
+ # include Leveret::Job
15
+ #
16
+ # queue_name 'my_custom_queue' # omit for default
17
+ # priority :high # omit for default
18
+ #
19
+ # def perform
20
+ # File.open('/tmp/leveret-test-file.txt', 'a+') do |f|
21
+ # f.puts params[:test_text]
22
+ # end
23
+ #
24
+ # sleep 5 # Job takes a long time
25
+ # end
26
+ # end
27
+ #
28
+ # @example Queueing a Job
29
+ # # With options defined in class
30
+ # MyJob.enqueue(test_text: "Hi there, please write this text to the file")
31
+ #
32
+ # # Set the job priority at queue time
33
+ # MyJob.enqueue(test_text: "Hi there, please write this important text to the file", priority: :high)
34
+ #
35
+ # # Place in a different queue to the one defined in the class
36
+ # MyJob.enqueue(test_text: "Hi there, please write this different text to the file", queue_name: 'other_queue')
37
+ #
38
+ module Job
39
+ # Raise this when your job has failed, but try again when a worker is
40
+ # available again.
41
+ class RequeueJob < StandardError; end
42
+
43
+ # Raise this when your job has failed, but you don't want to requeue it
44
+ # and try again.
45
+ class RejectJob < StandardError; end
46
+
47
+ # Instance methods to mixin with your job
48
+ module InstanceMethods
49
+ # @!attribute params
50
+ # @return [Paramters] The parameters required for task execution
51
+ attr_accessor :params
52
+
53
+ # Create a new job ready for execution
54
+ #
55
+ # @param [Parameters] params Parameters to be consumed by {#perform} when performing the job
56
+ def initialize(params = Parameters.new)
57
+ self.params = params
58
+ end
59
+
60
+ # Runs the job and captures any exceptions to turn them into symbols which represent the status of the job
61
+ #
62
+ # @return [Symbol] :success, :requeue, :reject depending on job success
63
+ def run
64
+ Leveret.log.info "Running #{self.class.name} with #{params}"
65
+ perform
66
+ :success
67
+ rescue Leveret::Job::RequeueJob
68
+ Leveret.log.warn "Requeueing job #{self.class.name} with #{params}"
69
+ :requeue
70
+ rescue Leveret::Job::RejectJob
71
+ Leveret.log.warn "Rejecting job #{self.class.name} with #{params}"
72
+ :reject
73
+ rescue StandardError => e
74
+ Leveret.log.error "#{e.message} when processing #{self.class.name} with #{params}"
75
+ Leveret.configuration.error_handler.call(e)
76
+ :reject
77
+ end
78
+
79
+ # Run the job with no error handling. Generally you should call {#run} to execute the job since that'll write
80
+ # and log output and call any error handlers if the job goes sideways.
81
+ #
82
+ # @note Your class should override this method to contain the work to be done in this job.
83
+ #
84
+ # @raise [RequeueJob] Reject this job and put it back on the queue for future execution
85
+ # @raise [RejectJob] Reject this job and do not requeue it.
86
+ def perform
87
+ raise NotImplementedError
88
+ end
89
+ end
90
+
91
+ # Class methods to mixin with your job
92
+ module ClassMethods
93
+ # Shorthand to intialize a new job and run it with error handling
94
+ #
95
+ # @param [Parameters] params Parameters to pass to the job for execution
96
+ #
97
+ # @return [Symbol] :success, :requeue or :reject depending on job execution
98
+ def perform(params = Parameters.new)
99
+ new(params).run
100
+ end
101
+
102
+ # Set a custom queue for this job
103
+ #
104
+ # @param [String] name Name of the queue to assign this job to
105
+ def queue_name(name)
106
+ job_options[:queue_name] = name
107
+ end
108
+
109
+ # Set a custom priority for this job
110
+ #
111
+ # @param [Symbol] priority Priority for this job, see {Queue::PRIORITY_MAP} for details
112
+ def priority(priority)
113
+ job_options[:priority] = priority
114
+ end
115
+
116
+ # @return [Hash] The current set of options for this job, the +queue_name+ and +priority+.
117
+ def job_options
118
+ @job_options ||= {
119
+ queue_name: Leveret.configuration.default_queue_name,
120
+ priority: :normal
121
+ }
122
+ end
123
+
124
+ # Place a job onto the queue for processing by a worker.
125
+ #
126
+ # @param [Hash] params The parameters to be included with the work request. These can be anything, however
127
+ # the keys +:priority+ and +:queue_name+ are reserved for customising those aspects of the job's execution
128
+ #
129
+ # @option params [Symbol] :priority (job_options[:priority]) Override the class-level priority for this job only
130
+ # @option params [String] :queue_name (job_options[:queue_name]) Override the class-level queue name for this
131
+ # job only.
132
+ def enqueue(params = {})
133
+ priority = params.delete(:priority) || job_options[:priority]
134
+ q_name = params.delete(:queue_name) || job_options[:queue_name]
135
+
136
+ Leveret.log.info "Queuing #{name} to #{q_name} (#{priority}) with #{params}"
137
+
138
+ payload = { job: self.name, params: params }
139
+ queue(q_name).publish(payload, priority: priority)
140
+ end
141
+
142
+ # @private Cache the queue for this job
143
+ #
144
+ # @param [q_name] The name of the queue we want to cache. If nil it'll use the name defined in
145
+ # +job_options[:queue_name]+
146
+ # @return [Queue] Cached Queue object for publishing jobs
147
+ def queue(q_name = nil)
148
+ q_name ||= job_options[:queue_name]
149
+ @queue ||= {}
150
+ @queue[q_name] ||= Leveret::Queue.new(q_name)
151
+ end
152
+ end
153
+
154
+ def self.included(base)
155
+ base.extend ClassMethods
156
+ base.include InstanceMethods
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,24 @@
1
+ module Leveret
2
+ # Prettier logging than the default
3
+ class LogFormatter < Logger::Formatter
4
+ # ANSI colour codes for different message types
5
+ SEVERITY_TO_COLOR_MAP = { 'DEBUG' => '0;37', 'INFO' => '32', 'WARN' => '33', 'ERROR' => '31', 'FATAL' => '31',
6
+ 'UNKNOWN' => '37' }.freeze
7
+
8
+ # Build a pretty formatted log line
9
+ #
10
+ # @param [String] severity Log level, one of debug, info, warn, error, fatal or unknown
11
+ # @param [Time] datetime Timestamp of log event
12
+ # @param [String] _progname (Unused) the name of the progname set in the logger
13
+ # @param [String] msg Body of the log message
14
+ #
15
+ # @return [String] Formatted and coloured log message in the format:
16
+ # "YYYY-MM-DD HH:MM:SS:USEC [SEVERITY] MESSAGE (pid:Process ID)"
17
+ def call(severity, datetime, _progname, msg)
18
+ formatted_time = datetime.strftime("%Y-%m-%d %H:%M:%S") << datetime.usec.to_s[0..2].rjust(3)
19
+ color = SEVERITY_TO_COLOR_MAP[severity]
20
+
21
+ "\033[0;37m#{formatted_time}\033[0m [\033[#{color}m#{severity}\033[0m] #{msg2str(msg)} (pid:#{Process.pid})\n"
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,59 @@
1
+ module Leveret
2
+ # Provides basic indifferent hash access for jobs, allows strings to be used to access symbol keyed values,
3
+ # or symbols to be used to access string keyed values.
4
+ #
5
+ # Overrides the [] method of the hash, all other calls (except {#serialize}) are delegated to the {#params} object
6
+ #
7
+ # Beware of using both strings and symbols with the same value in the same hash e.g. +{:one => 1, 'one' => 1}+
8
+ # only one of these values will ever be returned.
9
+ class Parameters
10
+ extend Forwardable
11
+
12
+ # The parameters hash wrapped up by this object
13
+ attr_accessor :params
14
+
15
+ def_delegators :params, :==, :inspect, :to_s
16
+
17
+ # @param [Hash] params Hash you wish to access indifferently
18
+ def initialize(params = {})
19
+ self.params = params || {}
20
+ end
21
+
22
+ # Access {#params} indifferently. Tries the passed key directly first, then tries it as a symbol, then tries
23
+ # it as a string.
24
+ #
25
+ # @param [Object] key Key of the item we're trying to access in {#params}
26
+ #
27
+ # @return [Object, nil] Value related to key, or nil if object is not found
28
+ def [](key)
29
+ params[key] || (key.respond_to?(:to_sym) && params[key.to_sym]) || (key.respond_to?(:to_s) && params[key.to_s])
30
+ end
31
+
32
+ # Delegate any unknown methods to the {#params} hash
33
+ def method_missing(method_name, *arguments, &block)
34
+ params.send(method_name, *arguments, &block)
35
+ end
36
+
37
+ # Check the {#params} hash as well as this class for a method's existence
38
+ def respond_to?(method_name, include_private = false)
39
+ params.respond_to?(method_name, include_private) || super
40
+ end
41
+
42
+ # Serialize the current value of {#params}. Outputs JSON.
43
+ #
44
+ # @return [String] JSON encoded representation of the params
45
+ def serialize
46
+ JSON.dump(params)
47
+ end
48
+
49
+ # Create a new instance of this class from a a serialized JSON object.
50
+ #
51
+ # @param [String] json JSON representation of the parameters we want to access
52
+ #
53
+ # @return [Parameters] New instance based on the passed JSON
54
+ def self.from_json(json)
55
+ params = JSON.load(json)
56
+ new(params)
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,105 @@
1
+ module Leveret
2
+ # Facilitates the publishing or subscribing of messages to the message queue.
3
+ #
4
+ # @!attribute [r] name
5
+ # @return [String] Name of the queue. This will have Leveret.queue_name_prefix prepended to it when creating a
6
+ # corresponding queue in RabbitMQ.
7
+ # @!attribute [r] queue
8
+ # @return [Bunny::Queue] The backend RabbitMQ queue
9
+ # @see http://reference.rubybunny.info/Bunny/Queue.html Bunny::Queue Documentation
10
+ class Queue
11
+ extend Forwardable
12
+
13
+ # Map the symbol names for priorities to the integers that RabbitMQ requires.
14
+ PRIORITY_MAP = { low: 0, normal: 1, high: 2 }.freeze
15
+ attr_reader :name, :queue
16
+
17
+ def_delegators :Leveret, :exchange, :channel, :log
18
+ def_delegators :queue, :pop, :purge
19
+
20
+ # Create a new queue with the name given in the params, if no name is given it will default to
21
+ # {Configuration#default_queue_name}. On instantiation constructor will immedaitely connect to
22
+ # RabbitMQ backend and create a queue with the appropriate name, or join an existing one.
23
+ #
24
+ # @param [String] name Name of the queue to connect to.
25
+ def initialize(name = nil)
26
+ @name = name || Leveret.configuration.default_queue_name
27
+ @queue = connect_to_queue
28
+ end
29
+
30
+ # Publish a mesage onto the queue. Fire and forget, this method is non-blocking and will not wait until
31
+ # the message is definitely on the queue.
32
+ #
33
+ # @param [Hash] payload The data we wish to send onto the queue, this will be serialized and automatically
34
+ # deserialized when received by a {#subscribe} block.
35
+ # @option options [Symbol] :priority (:normal) The priority this message should be treated with on the queue
36
+ # see {PRIORITY_MAP} for available options.
37
+ #
38
+ # @return [void]
39
+ def publish(payload, options = {})
40
+ priority_id = PRIORITY_MAP[options[:priority]] || PRIORITY_MAP[:normal]
41
+ payload = serialize_payload(payload)
42
+
43
+ log.debug "Publishing #{payload.inspect} for queue #{name} (Priority: #{priority_id})"
44
+ queue.publish(payload, persistent: true, routing_key: name, priority: priority_id)
45
+ end
46
+
47
+ # Subscribe to this queue and yield a block for every message received. This method does not block, receiving and
48
+ # dispatching of messages will be handled in a separate thread.
49
+ #
50
+ # The receiving block is responsible for acknowledging or rejecting the message. This must be done using the
51
+ # same channel the message was received # on, {#Leveret.channel}. {Worker#ack_message} provides an example
52
+ # implementation of this acknowledgement.
53
+ #
54
+ # @note The receiving block is responsible for acking/rejecting the message. Please see the note for more details.
55
+ #
56
+ # @yieldparam delivery_tag [String] The identifier for this message that must be used do ack/reject the message
57
+ # @yieldparam payload [Parameters] A deserialized version of the payload contained in the message
58
+ #
59
+ # @return [void]
60
+ def subscribe
61
+ log.info "Subscribing to #{name}"
62
+ queue.subscribe(manual_ack: true) do |delivery_info, _properties, msg|
63
+ log.debug "Received #{msg} from #{name}"
64
+ yield(delivery_info.delivery_tag, deserialize_payload(msg))
65
+ end
66
+ end
67
+
68
+ private
69
+
70
+ # Convert a set of parameters passed into a serialized form suitable for transport on the message queue
71
+ #
72
+ # @param [Hash] Paramets to be serialized
73
+ #
74
+ # @return [String] Encoded params ready to be sent onto the queue
75
+ def serialize_payload(params)
76
+ Leveret::Parameters.new(params).serialize
77
+ end
78
+
79
+ # Convert a set of serialized parameters into a {Parameters} object
80
+ #
81
+ # @param [String] JSON representation of the parameters
82
+ #
83
+ # @return [Parameters] Useful object representation of the parameters
84
+ def deserialize_payload(json)
85
+ Leveret::Parameters.from_json(json)
86
+ end
87
+
88
+ # Create or return a representation of the queue on the RabbitMQ backend
89
+ #
90
+ # @return [Bunny::Queue] RabbitMQ queue
91
+ def connect_to_queue
92
+ queue = channel.queue(mq_name, durable: true, auto_delete: false, arguments: { 'x-max-priority' => 2 })
93
+ queue.bind(exchange, routing_key: name)
94
+ log.debug "Connected to #{mq_name}, bound on #{name}"
95
+ queue
96
+ end
97
+
98
+ # Calculate the name of the queue that should be used on the RabbitMQ backend
99
+ #
100
+ # @return [String] Backend queue name
101
+ def mq_name
102
+ @mq_name ||= [Leveret.configuration.queue_name_prefix, name].join('_')
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,3 @@
1
+ module Leveret
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,147 @@
1
+ module Leveret
2
+ # Subscribes to one or more queues and forks workers to perform jobs as they arrive
3
+ #
4
+ # Call {#do_work} to subscribe to all queues and block the main thread.
5
+ class Worker
6
+ extend Forwardable
7
+
8
+ # @!attribute queues
9
+ # @return [Array<Queue>] All of the queues this worker is going to subscribe to
10
+ # @!attribute consumers
11
+ # @return [Array<Bunny::Consumer>] All of the actively subscribed queues
12
+ attr_accessor :queues, :consumers
13
+
14
+ def_delegators :Leveret, :log, :channel, :configuration
15
+
16
+ # Create a new worker to process jobs from the list of queue names passed
17
+ #
18
+ # @option options [Array<String>] queues ([Leveret.configuration.default_queue_name]) A list of queue names for
19
+ # this worker to subscribe to and process
20
+ # @option options [Integer] concurrent_fork_count (Leveret.configuration.concurrent_fork_count) How many messages
21
+ # at a time should this worker process?
22
+ def initialize(options = {})
23
+ options = {
24
+ queues: [configuration.default_queue_name],
25
+ concurrent_fork_count: [configuration.concurrent_fork_count]
26
+ }.merge(options)
27
+
28
+ Leveret.configuration.concurrent_fork_count = options[:concurrent_fork_count]
29
+
30
+ self.queues = options[:queues].map { |name| Leveret::Queue.new(name) }
31
+ self.consumers = []
32
+ @time_to_die = false
33
+ end
34
+
35
+ # Subscribe to all of the {#queues} and begin processing jobs from them. This will block the main
36
+ # thread until an interrupt is received.
37
+ def do_work
38
+ log.info "Starting master process for #{queues.map(&:name).join(', ')}"
39
+ prepare_for_work
40
+
41
+ loop do
42
+ if @time_to_die
43
+ cancel_subscriptions
44
+ break
45
+ end
46
+ sleep 1
47
+ end
48
+ log.info "Exiting master process"
49
+ end
50
+
51
+ private
52
+
53
+ # Steps that need to be prepared before we can begin processing jobs
54
+ def prepare_for_work
55
+ setup_traps
56
+ self.process_name = 'leveret-worker-parent'
57
+ start_subscriptions
58
+ end
59
+
60
+ # Catch INT and TERM signals and set an instance variable to signal the main loop to quit when possible
61
+ def setup_traps
62
+ trap('INT') do
63
+ @time_to_die = true
64
+ end
65
+ trap('TERM') do
66
+ @time_to_die = true
67
+ end
68
+ end
69
+
70
+ # Set the title of this process so it's easier on the eye in top
71
+ def process_name=(name)
72
+ Process.setproctitle(name)
73
+ end
74
+
75
+ # Subscribe to each queue defined in {#queues} and add the returned consumer to {#consumers}. This will
76
+ # allow us to gracefully cancel these subscriptions when we need to quit.
77
+ def start_subscriptions
78
+ queues.map do |queue|
79
+ consumers << queue.subscribe do |delivery_tag, payload|
80
+ fork_and_run(delivery_tag, payload)
81
+ end
82
+ end
83
+ end
84
+
85
+ # Send cancel to each consumer in the {#consumers} list. This will end the current subscription.
86
+ def cancel_subscriptions
87
+ log.info "Interrupt received, preparing to exit"
88
+ consumers.each do |consumer|
89
+ log.debug "Cancelling consumer on #{consumer.queue.name}"
90
+ consumer.cancel
91
+ end
92
+ end
93
+
94
+ # Fork the current process and run the job described by #payload in the newly created child process.
95
+ # Detach the main process from the child so we can return to the main loop without waiting for it to finish
96
+ # processing the job.
97
+ #
98
+ # @param [String] delivery_tag The identifier that RabbitMQ uses to track the message. This will be used to ack
99
+ # or reject the message after processing.
100
+ # @param [Parameters] payload The job name and parameters the job requires
101
+ def fork_and_run(delivery_tag, payload)
102
+ pid = fork do
103
+ self.process_name = 'leveret-worker-child'
104
+ log.info "[#{delivery_tag}] Forked to child process #{pid} to run #{payload[:job]}"
105
+
106
+ Leveret.configuration.after_fork.call
107
+
108
+ result = perform_job(payload)
109
+ log.info "[#{delivery_tag}] Job returned #{result}"
110
+ ack_message(delivery_tag, result)
111
+
112
+ log.info "[#{delivery_tag}] Exiting child process #{pid}"
113
+ exit!(0)
114
+ end
115
+
116
+ # Master doesn't need to know how it all went down, the worker will report it's own status back to the queue
117
+ Process.detach(pid)
118
+ end
119
+
120
+ # Constantize the class name in the payload and execute the job with parameters
121
+ #
122
+ # @param [Parameters] payload The job name and parameters the job requires
123
+ # @return [Symbol] :success, :reject or :requeue depending on how job execution went
124
+ def perform_job(payload)
125
+ job_klass = Object.const_get(payload[:job])
126
+ job_klass.perform(Leveret::Parameters.new(payload[:params]))
127
+ end
128
+
129
+ # Sends a message back to RabbitMQ confirming the completed execution of the message
130
+ #
131
+ # @param [String] delivery_tag The identifier that RabbitMQ uses to track the message. This will be used to ack
132
+ # or reject the message after processing.
133
+ # @param [Symbol] result :success, :reject or :requeue depending on how we want to acknowledge the message
134
+ def ack_message(delivery_tag, result)
135
+ if result == :reject
136
+ log.debug "[#{delivery_tag}] Rejecting message"
137
+ channel.reject(delivery_tag)
138
+ elsif result == :requeue
139
+ log.debug "[#{delivery_tag}] Requeueing message"
140
+ channel.reject(delivery_tag, true)
141
+ else
142
+ log.debug "[#{delivery_tag}] Acknowledging message"
143
+ channel.acknowledge(delivery_tag)
144
+ end
145
+ end
146
+ end
147
+ end
data/lib/leveret.rb ADDED
@@ -0,0 +1,78 @@
1
+ require 'bunny'
2
+ require 'json'
3
+ require 'logger'
4
+
5
+ require 'leveret/configuration'
6
+ require 'leveret/job'
7
+ require 'leveret/log_formatter'
8
+ require 'leveret/parameters'
9
+ require 'leveret/queue'
10
+ require 'leveret/worker'
11
+ require "leveret/version"
12
+
13
+ # Top level module, contains things that are required globally by Leveret, such as configuration,
14
+ # the RabbitMQ channel and the logger.
15
+ module Leveret
16
+ class << self
17
+ # @!attribute [w] configuration
18
+ # @return [Configuration] Set a totally new configuration object
19
+ attr_writer :configuration
20
+
21
+ # @return [Configuration] The current configuration of Leveret
22
+ def configuration
23
+ @configuration ||= Configuration.new
24
+ end
25
+
26
+ # Allows leveret to be configured via a block
27
+ #
28
+ # @see Configuration Attributes that can be configured
29
+ # @yield [config] The current configuration object
30
+ def configure
31
+ yield(configuration) if block_given?
32
+ end
33
+
34
+ # Connect to the RabbitMQ exchange that Leveret uses, used by the {Queue} for publishing and subscribing, not
35
+ # recommended for general use.
36
+ #
37
+ # @see http://reference.rubybunny.info/Bunny/Exchange.html Bunny documentation
38
+ # @return [Bunny::Exchange] RabbitMQ exchange
39
+ def exchange
40
+ @exchange ||= channel.exchange(Leveret.configuration.exchange_name, type: :direct, durable: true,
41
+ auto_delete: false)
42
+ end
43
+
44
+ # Connect to the RabbitMQ channel that {Queue} and {Worker} both use. This channel is not thread safe, so should
45
+ # be reinitialized if necessary. Not recommended for general use.
46
+ #
47
+ # @see http://reference.rubybunny.info/Bunny/Channel.html Bunny documentation
48
+ # @return [Bunny::Channel] RabbitMQ chanel
49
+ def channel
50
+ @channel ||= begin
51
+ chan = mq_connection.create_channel
52
+ chan.prefetch(configuration.concurrent_fork_count)
53
+ chan
54
+ end
55
+ end
56
+
57
+ # Logger used throughout Leveret, see {Configuration} for config options.
58
+ #
59
+ # @return [Logger] Standard ruby logger
60
+ def log
61
+ @log ||= Logger.new(configuration.log_file).tap do |log|
62
+ log.level = configuration.log_level
63
+ log.progname = 'Leveret'
64
+ log.formatter = Leveret::LogFormatter.new
65
+ end
66
+ end
67
+
68
+ private
69
+
70
+ def mq_connection
71
+ @mq_connection ||= begin
72
+ conn = Bunny.new(amqp: configuration.amqp)
73
+ conn.start
74
+ conn
75
+ end
76
+ end
77
+ end
78
+ end
metadata ADDED
@@ -0,0 +1,133 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: leveret
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Dan Wentworth
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2016-07-27 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: bunny
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '2.3'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '2.3'
27
+ - !ruby/object:Gem::Dependency
28
+ name: json
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.8'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1.8'
41
+ - !ruby/object:Gem::Dependency
42
+ name: bundler
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '1.10'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '1.10'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rake
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '10.0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '10.0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rspec
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ description:
84
+ email:
85
+ - dan@atechmedia.com
86
+ executables:
87
+ - leveret_worker
88
+ extensions: []
89
+ extra_rdoc_files: []
90
+ files:
91
+ - ".rspec"
92
+ - ".travis.yml"
93
+ - Gemfile
94
+ - LICENSE.txt
95
+ - README.md
96
+ - Rakefile
97
+ - bin/console
98
+ - bin/setup
99
+ - exe/leveret_worker
100
+ - leveret.gemspec
101
+ - lib/leveret.rb
102
+ - lib/leveret/configuration.rb
103
+ - lib/leveret/job.rb
104
+ - lib/leveret/log_formatter.rb
105
+ - lib/leveret/parameters.rb
106
+ - lib/leveret/queue.rb
107
+ - lib/leveret/version.rb
108
+ - lib/leveret/worker.rb
109
+ homepage: https://github.com/darkphnx/leveret
110
+ licenses:
111
+ - MIT
112
+ metadata: {}
113
+ post_install_message:
114
+ rdoc_options: []
115
+ require_paths:
116
+ - lib
117
+ required_ruby_version: !ruby/object:Gem::Requirement
118
+ requirements:
119
+ - - ">="
120
+ - !ruby/object:Gem::Version
121
+ version: '0'
122
+ required_rubygems_version: !ruby/object:Gem::Requirement
123
+ requirements:
124
+ - - ">="
125
+ - !ruby/object:Gem::Version
126
+ version: '0'
127
+ requirements: []
128
+ rubyforge_project:
129
+ rubygems_version: 2.5.1
130
+ signing_key:
131
+ specification_version: 4
132
+ summary: Simple RabbitMQ backed backround worker
133
+ test_files: []