queue_classic_plus 0.0.2

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 8acdaf6cd41362f50d7ddb9c50fa87555c00adc9
4
+ data.tar.gz: 58c26d31a05c2390504975b6a7513179f0397ede
5
+ SHA512:
6
+ metadata.gz: 453a051c64c4c539f488f9ea7f0c01da9ac326de11891746b6b0abea14bc4103bb347afd4020bedb0b37c0262d1c1a983659f39c0b35909521db6af2951dcacb
7
+ data.tar.gz: 37263c41d6929a351e74920a8abe5d2d4d3bbc3c5c9bdb941f4cd2e6758ef2443ddf580dd16298124809ca0232ce7df02b3533fac75fef8fa911f21c45ecb597
data/.gitignore ADDED
@@ -0,0 +1,23 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .yardoc
6
+ Gemfile.lock
7
+ InstalledFiles
8
+ _yardoc
9
+ coverage
10
+ doc/
11
+ lib/bundler/man
12
+ pkg
13
+ rdoc
14
+ spec/reports
15
+ test/tmp
16
+ test/version_tmp
17
+ tmp
18
+ *.bundle
19
+ *.so
20
+ *.o
21
+ *.a
22
+ mkmf.log
23
+ tags
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --color
2
+ --require spec_helper
data/.rvmrc ADDED
@@ -0,0 +1 @@
1
+ rvm use 2.1.3@queue_classic_plus --create
data/Gemfile ADDED
@@ -0,0 +1,11 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in queue_classic_plus.gemspec
4
+ gemspec
5
+
6
+
7
+ gem 'rspec'
8
+ gem 'pg'
9
+ gem 'timecop'
10
+ gem 'queue_classic-later', github: 'jipiboily/queue_classic-later', branch: "add-qc-3-to-custom-columns" # This is until the 3.0 work is merged into original repo
11
+ gem 'queue_classic_matchers'
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2014 CLDRDR Inc.
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,97 @@
1
+ # QueueClassicPlus
2
+
3
+ QueueClassic is a simple Postgresql back DB queue. However, it's a little too simple to use it as the main queueing system of a medium to large app.
4
+
5
+ QueueClassicPlus adds many lacking features to QueueClassic.
6
+
7
+ - Standard job format
8
+ - Retry on specific exceptions
9
+ - Singleton jobs
10
+ - Metrics
11
+ - Error logging / handling
12
+ - Transactions
13
+
14
+ ## Installation
15
+
16
+ Add these line to your application's Gemfile:
17
+
18
+ gem 'queue_classic_plus'
19
+ gem "queue_classic-later", github: "dpiddy/queue_classic-later"
20
+
21
+ And then execute:
22
+
23
+ $ bundle
24
+
25
+ Run the migration
26
+
27
+ ```ruby
28
+ QueueClassicPlus.migrate
29
+ ```
30
+
31
+ ## Usage
32
+
33
+ ### Create a new job
34
+
35
+ ```bash
36
+ rails g my_job test_job
37
+ ```
38
+
39
+ ```ruby
40
+ # /app/jobs/my_job.rb
41
+ class Jobs::MyJob < QueueClassicPlus::Base
42
+ # Specified the queue name
43
+ @queue = :low
44
+
45
+ # Extry up to 5 times when SomeException is raised
46
+ retry! on: SomeException, max: 5
47
+
48
+ def self.perform(a, b)
49
+ # ...
50
+ end
51
+ end
52
+
53
+ # In your code, you can enqueue this task like so:
54
+ Jobs::MyJob.do(1, "foo")
55
+
56
+ # You can also schedule a job in the future by doing
57
+
58
+ Jobs::MyJob.enqueue_perform_in(1.hour, 1, "foo")
59
+ ```
60
+
61
+ ### Run the QueueClassicPlus worker
62
+
63
+ ```
64
+ QUEUE=low bundle exec qc_plus:work
65
+ ```
66
+
67
+ ## Advance configuration
68
+
69
+ If you want to log exceptions in your favorite exception tracker. You can configured it like sso:
70
+
71
+ ```ruby
72
+ QueueClassicPlus.exception_handler = -> (exception, job) do
73
+ Raven.capture_exception(exception, extra: {job: job, env: ENV})
74
+ end
75
+ ```
76
+
77
+ If you use Librato, we push useful metrics directly to them.
78
+
79
+ Push metrics to your metric provider (only Librato is supported for now).
80
+
81
+ ```ruby
82
+ QueueClassicPlus.update_metrics
83
+ ```
84
+
85
+ Call this is a cron job or something similar.
86
+
87
+ ## TODO
88
+
89
+ - Remove dep on ActiveRecord
90
+
91
+ ## Contributing
92
+
93
+ 1. Fork it ( https://github.com/[my-github-username]/queue_classic_plus/fork )
94
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
95
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
96
+ 4. Push to the branch (`git push origin my-new-feature`)
97
+ 5. Create a new Pull Request
data/Rakefile ADDED
@@ -0,0 +1,2 @@
1
+ require "bundler/gem_tasks"
2
+
@@ -0,0 +1,127 @@
1
+ module QueueClassicPlus
2
+ class Base
3
+ def self.queue
4
+ QC::Queue.new(@queue)
5
+ end
6
+
7
+ class_attribute :locked
8
+ class_attribute :skip_transaction
9
+ class_attribute :retries_on
10
+ class_attribute :max_retries
11
+
12
+ self.max_retries = 0
13
+ self.retries_on = {}
14
+
15
+ def self.retry!(on: RuntimeError, max: 5)
16
+ Array(on).each {|e| self.retries_on[e] = true}
17
+ self.max_retries = max
18
+ end
19
+
20
+ def self.retries_on? exception
21
+ self.retries_on[exception.class] || self.retries_on.keys.any? {|klass| exception.is_a? klass}
22
+ end
23
+
24
+ def self.lock!
25
+ self.locked = true
26
+ end
27
+
28
+ def self.skip_transaction!
29
+ self.skip_transaction = true
30
+ end
31
+
32
+ def self.locked?
33
+ !!self.locked
34
+ end
35
+
36
+ def self.logger
37
+ QueueClassicPlus.logger
38
+ end
39
+
40
+ def self.can_enqueue?(method, *args)
41
+ if locked?
42
+ lock_key = [@queue, method, args].to_json
43
+ max_lock_time = ENV.fetch("QUEUE_CLASSIC_MAX_LOCK_TIME", 10 * 60).to_i
44
+
45
+ ActiveRecord::Base.with_advisory_lock(lock_key, 0.1) do
46
+ q = "SELECT COUNT(1) AS count
47
+ FROM
48
+ (
49
+ (
50
+ SELECT 1
51
+ FROM queue_classic_jobs
52
+ WHERE q_name = $1 AND method = $2 AND args::text = $3::text
53
+ AND (locked_at IS NULL OR locked_at > current_timestamp - interval '#{max_lock_time} seconds')
54
+ LIMIT 1
55
+ )
56
+
57
+ UNION
58
+
59
+ (
60
+ SELECT 1
61
+ FROM queue_classic_later_jobs
62
+ WHERE q_name = $4 AND method = $5 AND args = $6::text
63
+ LIMIT 1
64
+ )
65
+ )
66
+ AS x"
67
+
68
+ result = QC.default_conn_adapter.execute(q, @queue, method, args.to_json, @queue, method, args.to_json)
69
+ result['count'].to_i == 0
70
+ end
71
+ else
72
+ true
73
+ end
74
+ end
75
+
76
+ def self.enqueue(method, *args)
77
+ if can_enqueue?(method, *args)
78
+ queue.enqueue(method, *args)
79
+ end
80
+ end
81
+
82
+ def self.enqueue_perform(*args)
83
+ enqueue("#{self.to_s}._perform", *args)
84
+ end
85
+
86
+ def self.enqueue_perform_in(time, *args)
87
+ raise "Can't enqueue in the future for locked jobs" if locked?
88
+ queue.enqueue_in(time, "#{self.to_s}._perform", *args)
89
+ end
90
+
91
+ def self.restart_in(time, remaining_retries, *args)
92
+ queue.enqueue_in_with_custom(time, "#{self.to_s}._perform", {'remaining_retries' => remaining_retries}, *args)
93
+ end
94
+
95
+ def self.do(*args)
96
+ Metrics.timing("qc_enqueue_time", source: librato_key) do
97
+ enqueue_perform(*args)
98
+ end
99
+ end
100
+
101
+ def self._perform(*args)
102
+ Metrics.timing("qu_perform_time", source: librato_key) do
103
+ if skip_transaction
104
+ perform *args
105
+ else
106
+ transaction do
107
+ perform *args
108
+ end
109
+ end
110
+ end
111
+ end
112
+
113
+ def self.librato_key
114
+ (self.name || "").underscore.gsub(/\//, ".")
115
+ end
116
+
117
+ def self.transaction(options = {}, &block)
118
+ ActiveRecord::Base.transaction(options, &block)
119
+ end
120
+
121
+ # Debugging
122
+ def self.list
123
+ q = "SELECT * FROM queue_classic_jobs WHERE q_name = '#{@queue}'"
124
+ ActiveRecord::Base.connection.execute(q).to_a
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,29 @@
1
+ module QueueClassicPlus
2
+ class Empty
3
+ def self.method_missing(*)
4
+ yield if block_given?
5
+ end
6
+ end
7
+
8
+ class Metrics
9
+ def self.timing(*args, &block)
10
+ provider.timing *args, &block
11
+ end
12
+
13
+ def self.increment(*args)
14
+ provider.increment(*args)
15
+ end
16
+
17
+ def self.measure(*args)
18
+ provider.measure(*args)
19
+ end
20
+
21
+ def self.provider
22
+ if defined?(Librato)
23
+ Librato
24
+ else
25
+ Empty
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,12 @@
1
+ require 'rails'
2
+
3
+ module MyPlugin
4
+ class Railtie < Rails::Railtie
5
+ railtie_name :queue_classic_plus
6
+
7
+ rake_tasks do
8
+ load "queue_classic_plus/tasks/work.rake"
9
+ end
10
+ end
11
+ end
12
+
@@ -0,0 +1,23 @@
1
+ namespace :qc_plus do
2
+ desc "Start a new worker for the (default or $QUEUE) queue"
3
+ task :work => :environment do
4
+ puts "Starting up worker for queue #{ENV['QUEUE']}"
5
+ @worker = QueueClassicPlus::CustomWorker.new
6
+
7
+ trap('INT') do
8
+ $stderr.puts("Received INT. Shutting down.")
9
+ if !@worker.running
10
+ $stderr.puts("Worker has stopped running. Exit.")
11
+ exit(1)
12
+ end
13
+ @worker.stop
14
+ end
15
+
16
+ trap('TERM') do
17
+ $stderr.puts("Received Term. Shutting down.")
18
+ @worker.stop
19
+ end
20
+
21
+ @worker.start
22
+ end
23
+ end
@@ -0,0 +1,62 @@
1
+ module QueueClassicPlus
2
+ module UpdateMetrics
3
+ def self.update
4
+ {
5
+ "queued" => "queue_classic_jobs",
6
+ "scheduled" => "queue_classic_later_jobs",
7
+ }.each do |type, table|
8
+ next unless ActiveRecord::Base.connection.table_exists?(table)
9
+ q = "SELECT q_name, COUNT(1) FROM #{table} GROUP BY q_name"
10
+ results = execute(q)
11
+
12
+ # Log individual queue sizes
13
+ results.each do |h|
14
+ Metrics.measure("qc.jobs_#{type}", h.fetch('count').to_i, source: h.fetch('q_name'))
15
+ end
16
+ end
17
+
18
+ # Log oldest locked_at and created_at
19
+ ['locked_at', 'created_at'].each do |column|
20
+ age = max_age(column)
21
+ Metrics.measure("qc.max_#{column}", age)
22
+ end
23
+
24
+ # Log oldes unlocked jobs
25
+ age = max_age("created_at", "locked_at IS NULL")
26
+ Metrics.measure("qc.max_created_at.unlocked", age)
27
+
28
+ if ActiveRecord::Base.connection.table_exists?('queue_classic_later_jobs')
29
+ lag = execute("SELECT MAX(EXTRACT(EPOCH FROM now() - not_before)) AS lag
30
+ FROM queue_classic_later_jobs").first
31
+ lag = lag ? lag['lag'] : 0
32
+
33
+ Metrics.measure("qc.jobs_delayed.lag", lag.to_f)
34
+
35
+ nb_late = execute("SELECT COUNT(1)
36
+ FROM queue_classic_later_jobs
37
+ WHERE not_before < NOW()").first
38
+ nb_late = nb_late ? nb_late['count'] : 0
39
+
40
+ Metrics.measure("qc.jobs_delayed.late_count", nb_late.to_i)
41
+ end
42
+ end
43
+
44
+ private
45
+ def self.max_age(column, *conditions)
46
+ conditions.unshift "q_name != '#{::QueueClassicPlus::CustomWorker::FailedQueue.name}'"
47
+
48
+ q = "SELECT EXTRACT(EPOCH FROM now() - #{column}) AS age_in_seconds
49
+ FROM queue_classic_jobs
50
+ WHERE #{conditions.join(" AND ")}
51
+ ORDER BY age_in_seconds DESC
52
+ "
53
+ age_info = execute(q).first
54
+
55
+ age_info ? age_info['age_in_seconds'].to_i : 0
56
+ end
57
+
58
+ def self.execute(q)
59
+ ActiveRecord::Base.connection.execute(q)
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,3 @@
1
+ module QueueClassicPlus
2
+ VERSION = "0.0.2"
3
+ end
@@ -0,0 +1,54 @@
1
+ module QueueClassicPlus
2
+ class CustomWorker < QC::Worker
3
+ class QueueClassicJob < ActiveRecord::Base
4
+ end
5
+
6
+ BACKOFF_WIDTH = 10
7
+ FailedQueue = QC::Queue.new("failed_jobs")
8
+
9
+ def enqueue_failed(job, e)
10
+ ActiveRecord::Base.transaction do
11
+ FailedQueue.enqueue(job[:method], *job[:args])
12
+ new_job = QueueClassicJob.order(:id).where(q_name: 'failed_jobs').last
13
+ new_job.last_error = if e.backtrace then ([e.message] + e.backtrace ).join("\n") else e.message end
14
+ new_job.save!
15
+ end
16
+
17
+ QueueClassicPlus.exception_handler.call(e, job)
18
+ Metrics.increment("qc.errors", source: @q_name)
19
+ end
20
+
21
+ def handle_failure(job, e)
22
+ QueueClassicPlus.logger.info "Handling exception #{e.message} for job #{job[:id]}"
23
+ klass = job_klass(job)
24
+
25
+ model = QueueClassicJob.find(job[:id])
26
+
27
+ # The mailers doesn't have a retries_on?
28
+ if klass && klass.respond_to?(:retries_on?) && klass.retries_on?(e)
29
+ remaining_retries = model.remaining_retries || klass.max_retries
30
+ remaining_retries -= 1
31
+
32
+ if remaining_retries > 0
33
+ klass.restart_in((klass.max_retries - remaining_retries) * BACKOFF_WIDTH,
34
+ remaining_retries,
35
+ *job[:args])
36
+ else
37
+ enqueue_failed(job, e)
38
+ end
39
+ else
40
+ enqueue_failed(job, e)
41
+ end
42
+ model.destroy
43
+ end
44
+
45
+ private
46
+ def job_klass(job)
47
+ begin
48
+ Object.const_get(job[:method].split('.')[0])
49
+ rescue NameError
50
+ nil
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,49 @@
1
+ require "queue_classic"
2
+ require "queue_classic/later"
3
+ require "active_record"
4
+ require "with_advisory_lock"
5
+ require "active_support/core_ext/class/attribute"
6
+
7
+ require "queue_classic_plus/version"
8
+ require "queue_classic_plus/metrics"
9
+ require "queue_classic_plus/update_metrics"
10
+ require "queue_classic_plus/base"
11
+ require "queue_classic_plus/worker"
12
+
13
+ module QueueClassicPlus
14
+ require 'queue_classic_plus/railtie' if defined?(Rails)
15
+
16
+ def self.migrate(c = QC::default_conn_adapter.connection)
17
+ conn = QC::ConnAdapter.new(c)
18
+ conn.execute("ALTER TABLE queue_classic_jobs ADD COLUMN last_error TEXT")
19
+ conn.execute("ALTER TABLE queue_classic_jobs ADD COLUMN remaining_retries INTEGER")
20
+ conn.execute("ALTER TABLE queue_classic_later_jobs ADD COLUMN remaining_retries INTEGER")
21
+ end
22
+
23
+ def self.demigrate(c = QC::default_conn_adapter.connection)
24
+ conn = QC::ConnAdapter.new(c)
25
+ conn.execute("ALTER TABLE queue_classic_jobs DROP COLUMN last_error")
26
+ conn.execute("ALTER TABLE queue_classic_jobs DROP COLUMN remaining_retries")
27
+ conn.execute("ALTER TABLE queue_classic_later_jobs DROP COLUMN remaining_retries")
28
+ end
29
+
30
+ def self.exception_handler
31
+ @exception_handler ||= -> (exception, job) { nil }
32
+ end
33
+
34
+ def self.exception_handler=(handler)
35
+ @exception_handler = handler
36
+ end
37
+
38
+ def self.update_metrics
39
+ UpdateMetrics.update
40
+ end
41
+
42
+ def self.logger
43
+ @logger ||= defined?(Rails) ? Rails.logger : Logger.new(STDOUT)
44
+ end
45
+
46
+ def self.logger=(l)
47
+ @logger = l
48
+ end
49
+ end
@@ -0,0 +1,11 @@
1
+ class QcPlusJobGenerator < Rails::Generators::NamedBase
2
+ source_root File.expand_path('../templates', __FILE__)
3
+
4
+ def generate_lib
5
+ template "job.rb.erb", File.join("app", "jobs", class_path, "#{file_name}.rb")
6
+ if defined?(RSpec)
7
+ template "job_spec.rb.erb", File.join("spec", "jobs", class_path, "#{file_name}_spec.rb")
8
+ end
9
+ end
10
+ end
11
+
@@ -0,0 +1,8 @@
1
+ <% module_namespacing do -%>
2
+ class Jobs::<%= class_name %> < QueueClassicPlus::Base
3
+ @queue = :low
4
+
5
+ def self.perform
6
+ end
7
+ end
8
+ <% end -%>
@@ -0,0 +1,13 @@
1
+ <% module_namespacing do -%>
2
+ describe Jobs::<%= class_name %>, type: :job do
3
+ it_should_behave_like "a queueable class"
4
+
5
+ describe ".perform" do
6
+ it "does not die" do
7
+ expect do
8
+ described_class.perform
9
+ end.to_not raise_error
10
+ end
11
+ end
12
+ end
13
+ <% end -%>
@@ -0,0 +1,28 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'queue_classic_plus/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "queue_classic_plus"
8
+ spec.version = QueueClassicPlus::VERSION
9
+ spec.authors = ["Simon Mathieu", "Russell Smith", "Jean-Philippe Boily"]
10
+ spec.email = ["simon.math@gmail.com", "russ@rainforestqa.com", "j@jipi.ca"]
11
+ spec.summary = %q{Useful extras for Queue Classic}
12
+ spec.description = %q{}
13
+ spec.homepage = ""
14
+ spec.license = "MIT"
15
+
16
+ spec.files = `git ls-files -z`.split("\x0")
17
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
18
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
19
+ spec.require_paths = ["lib"]
20
+
21
+ spec.add_dependency "queue_classic"
22
+ spec.add_dependency "queue_classic-later"
23
+ spec.add_dependency "activerecord", "> 3.0"
24
+ spec.add_dependency "activesupport", "> 3.0"
25
+ spec.add_dependency "with_advisory_lock"
26
+ spec.add_development_dependency "bundler", "~> 1.6"
27
+ spec.add_development_dependency "rake"
28
+ end
data/spec/base_spec.rb ADDED
@@ -0,0 +1,156 @@
1
+
2
+ describe QueueClassicPlus::Base do
3
+ context "A child of QueueClassicPlus::Base" do
4
+ context "that is locked" do
5
+ subject do
6
+ Class.new(QueueClassicPlus::Base) do
7
+ @queue = :test
8
+ lock!
9
+ end
10
+ end
11
+
12
+ it "does not allow multiple enqueues" do
13
+ subject.do
14
+ subject.do
15
+ subject.should have_queue_size_of(1)
16
+ end
17
+
18
+ it "does allow multiple enqueues if something got locked for too long" do
19
+ subject.do
20
+ ActiveRecord::Base.connection.execute "
21
+ UPDATE queue_classic_jobs SET locked_at = '#{1.day.ago.to_s}' WHERE q_name = 'test'
22
+ "
23
+ subject.do
24
+ subject.should have_queue_size_of(2)
25
+ end
26
+ end
27
+
28
+ context "with default settings" do
29
+ subject do
30
+ Class.new(QueueClassicPlus::Base) do
31
+ @queue = :test
32
+
33
+ def self.perform
34
+ end
35
+
36
+ def self.name
37
+ "Funky::Name"
38
+ end
39
+ end
40
+ end
41
+
42
+ it "calls perform in a transaction" do
43
+ ActiveRecord::Base.should_receive(:transaction).and_call_original
44
+ subject._perform
45
+ end
46
+
47
+ it "measures the time" do
48
+ QueueClassicPlus::Metrics.should_receive(:timing).with("qu_perform_time", {source: "funky.name"}).and_call_original
49
+ subject._perform
50
+ end
51
+ end
52
+
53
+ context "skips transaction" do
54
+ subject do
55
+ Class.new(QueueClassicPlus::Base) do
56
+ @queue = :test
57
+ skip_transaction!
58
+
59
+ def self.perform
60
+ end
61
+ end
62
+ end
63
+
64
+ it "calls perform outside of a transaction" do
65
+ ActiveRecord::Base.should_not_receive(:transaction)
66
+ subject._perform
67
+ end
68
+ end
69
+
70
+ context "retries on single exception" do
71
+ subject do
72
+ Class.new(QueueClassicPlus::Base) do
73
+ @queue = :test
74
+ retry! on: SomeException, max: 5
75
+ skip_transaction!
76
+
77
+ def self.perform
78
+ end
79
+ end
80
+ end
81
+
82
+ it "retries on specified exception" do
83
+ subject.retries_on?(SomeException.new).should be(true)
84
+ end
85
+
86
+ it "does not retry on unspecified exceptions" do
87
+ subject.retries_on?(RuntimeError).should be(false)
88
+ end
89
+
90
+ it "sets max retries" do
91
+ subject.max_retries.should == 5
92
+ end
93
+ end
94
+
95
+ context "retries on multiple exceptions" do
96
+ subject do
97
+ Class.new(QueueClassicPlus::Base) do
98
+ @queue = :test
99
+ retry! on: [SomeException, SomeOtherException], max: 5
100
+ skip_transaction!
101
+
102
+ def self.perform
103
+ end
104
+ end
105
+ end
106
+
107
+ it "retries on all specified exceptions" do
108
+ subject.retries_on?(SomeException.new).should be(true)
109
+ subject.retries_on?(SomeOtherException.new).should be(true)
110
+ end
111
+
112
+ it "does not retry on unspecified exceptions" do
113
+ subject.retries_on?(RuntimeError).should be(false)
114
+ end
115
+
116
+ it "sets max retries" do
117
+ subject.max_retries.should == 5
118
+ end
119
+ end
120
+
121
+ context "handles exception subclasses" do
122
+ class ServiceReallyUnavailable < SomeException
123
+ end
124
+
125
+ subject do
126
+ Class.new(QueueClassicPlus::Base) do
127
+ @queue = :test
128
+ retry! on: SomeException, max: 5
129
+ skip_transaction!
130
+
131
+ def self.perform
132
+ end
133
+ end
134
+ end
135
+
136
+ it "retries on a subclass of a specified exception" do
137
+ subject.retries_on?(ServiceReallyUnavailable.new).should be(true)
138
+ end
139
+
140
+ it "does not retry on unspecified exceptions" do
141
+ subject.retries_on?(RuntimeError).should be(false)
142
+ end
143
+
144
+ it "sets max retries" do
145
+ subject.max_retries.should == 5
146
+ end
147
+ end
148
+ end
149
+
150
+ describe ".librato_key" do
151
+ it "removes unsupported caracter from the classname" do
152
+ Jobs::Tests::TestJob.librato_key.should == 'jobs.tests.test_job'
153
+ end
154
+ end
155
+ end
156
+
@@ -0,0 +1,43 @@
1
+ class SomeException < RuntimeError
2
+ end
3
+
4
+ class SomeOtherException < RuntimeError
5
+ end
6
+
7
+ module Jobs
8
+ module Tests
9
+ class LockedTestJob < QueueClassicPlus::Base
10
+ lock!
11
+
12
+ @queue = :low
13
+ retry! on: SomeException, max: 5
14
+
15
+ def self.perform should_raise
16
+ raise SomeException if should_raise
17
+ end
18
+ end
19
+
20
+
21
+ class TestJobNoRetry < QueueClassicPlus::Base
22
+ class Custom < RuntimeError
23
+ end
24
+
25
+ @queue = :low
26
+
27
+ def self.perform should_raise
28
+ raise Custom if should_raise
29
+ end
30
+ end
31
+
32
+
33
+ class TestJob < QueueClassicPlus::Base
34
+ @queue = :low
35
+ retry! on: SomeException, max: 5
36
+
37
+ def self.perform should_raise
38
+ raise SomeException if should_raise
39
+ end
40
+ end
41
+
42
+ end
43
+ end
@@ -0,0 +1,31 @@
1
+ require 'queue_classic_plus'
2
+ require 'pg'
3
+ require 'timecop'
4
+ require 'queue_classic_matchers'
5
+ require_relative './sample_jobs'
6
+
7
+ RSpec.configure do |config|
8
+ config.before(:suite) do
9
+ ActiveRecord::Base.establish_connection(
10
+ :adapter => "postgresql",
11
+ :username => "postgres",
12
+ :database => "queue_classic_plus_test",
13
+ :host => 'localhost',
14
+ )
15
+
16
+ ActiveRecord::Base.connection.execute "drop schema public cascade; create schema public;"
17
+
18
+ QC.default_conn_adapter = QC::ConnAdapter.new(ActiveRecord::Base.connection.raw_connection)
19
+ QC::Setup.create
20
+ QC::Later::Setup.create
21
+ QueueClassicPlus.migrate
22
+ end
23
+
24
+ config.before(:each) do
25
+ tables = ActiveRecord::Base.connection.tables.select do |table|
26
+ table != "schema_migrations"
27
+ end
28
+ ActiveRecord::Base.connection.execute("TRUNCATE #{tables.join(', ')} CASCADE") unless tables.empty?
29
+
30
+ end
31
+ end
@@ -0,0 +1,129 @@
1
+ require 'spec_helper'
2
+
3
+ describe QueueClassicPlus::CustomWorker do
4
+ class QueueClassicLaterJob < ActiveRecord::Base
5
+ end
6
+
7
+ class QueueClassicJob < ActiveRecord::Base
8
+ end
9
+
10
+ let(:failed_queue) { described_class::FailedQueue }
11
+
12
+ context "failure" do
13
+ let(:queue) { QC::Queue.new("test") }
14
+ let(:worker) { described_class.new q_name: queue.name }
15
+
16
+ it "record failures in the failed queue" do
17
+ queue.enqueue("Kerklfadsjflaksj", 1, 2, 3)
18
+ failed_queue.count.should == 0
19
+ worker.work
20
+ failed_queue.count.should == 1
21
+ job = failed_queue.lock
22
+ job[:method].should == "Kerklfadsjflaksj"
23
+ job[:args].should == [1, 2, 3]
24
+ QueueClassicJob.last.last_error.should be_present
25
+ end
26
+
27
+ it "records normal errors" do
28
+ queue.enqueue("Jobs::Tests::TestJobNoRetry.perform", true)
29
+ failed_queue.count.should == 0
30
+ worker.work
31
+ failed_queue.count.should == 1
32
+ end
33
+ end
34
+
35
+ context "retry" do
36
+ let(:job_type) { Jobs::Tests::LockedTestJob }
37
+ let(:worker) { described_class.new q_name: job_type.queue.name }
38
+ let(:enqueue_expected_ts) { described_class::BACKOFF_WIDTH.seconds.from_now }
39
+
40
+ before do
41
+ job_type.skip_transaction!
42
+ end
43
+
44
+ it "retries" do
45
+ expect do
46
+ job_type.enqueue_perform(true)
47
+ end.to change_queue_size_of(job_type).by(1)
48
+
49
+ Jobs::Tests::LockedTestJob.should have_queue_size_of(1)
50
+ failed_queue.count.should == 0
51
+ QueueClassicMatchers::QueueClassicRspec.find_by_args('low', 'Jobs::Tests::LockedTestJob._perform', [true]).first['remaining_retries'].should be_nil
52
+
53
+ Timecop.freeze do
54
+ expect do
55
+ worker.work
56
+ end.to change_queue_size_of(job_type).by(-1)
57
+
58
+ failed_queue.count.should == 0 # not enqueued on Failed
59
+ Jobs::Tests::LockedTestJob.should have_scheduled(true).at(Time.now + described_class::BACKOFF_WIDTH.seconds.to_i) # should have scheduled a retry for later
60
+ end
61
+
62
+ Timecop.freeze(Time.now + (described_class::BACKOFF_WIDTH.seconds.to_i * 2)) do
63
+ QC::Later.tick(true)
64
+ # the job should be re-enqueued with a decremented retry count
65
+ jobs = QueueClassicMatchers::QueueClassicRspec.find_by_args('low', 'Jobs::Tests::LockedTestJob._perform', [true])
66
+ jobs.size.should == 1
67
+ job = jobs.first
68
+ job['remaining_retries'].to_i.should == job_type.max_retries - 1
69
+ job['locked_by'].should be_nil
70
+ job['locked_at'].should be_nil
71
+ end
72
+ end
73
+
74
+ it "enqueues in the failed queue when retries have been exhausted" do
75
+ job_type.max_retries = 0
76
+ expect do
77
+ job_type.enqueue_perform(true)
78
+ end.to change_queue_size_of(job_type).by(1)
79
+
80
+ Jobs::Tests::LockedTestJob.should have_queue_size_of(1)
81
+ failed_queue.count.should == 0
82
+ QueueClassicMatchers::QueueClassicRspec.find_by_args('low', 'Jobs::Tests::LockedTestJob._perform', [true]).first['remaining_retries'].should be_nil
83
+
84
+ Timecop.freeze do
85
+ expect do
86
+ worker.work
87
+ end.to change_queue_size_of(job_type).by(-1)
88
+
89
+ failed_queue.count.should == 1 # not enqueued on Failed
90
+ Jobs::Tests::LockedTestJob.should_not have_scheduled(true).at(Time.now + described_class::BACKOFF_WIDTH.seconds.to_i) # should have scheduled a retry for later
91
+ end
92
+ end
93
+ end
94
+
95
+ context "enqueuing during a retry" do
96
+ let(:job_type) { Jobs::Tests::LockedTestJob }
97
+ let(:worker) { described_class.new q_name: job_type.queue.name }
98
+ let(:enqueue_expected_ts) { described_class::BACKOFF_WIDTH.seconds.from_now }
99
+
100
+ before do
101
+ job_type.max_retries = 5
102
+ job_type.skip_transaction!
103
+ end
104
+
105
+ it "does not enqueue in main queue while retrying" do
106
+ expect do
107
+ job_type.enqueue_perform(true)
108
+ end.to change_queue_size_of(job_type).by(1)
109
+
110
+ Jobs::Tests::LockedTestJob.should have_queue_size_of(1)
111
+ failed_queue.count.should == 0
112
+ QueueClassicMatchers::QueueClassicRspec.find_by_args('low', 'Jobs::Tests::LockedTestJob._perform', [true]).first['remaining_retries'].should be_nil
113
+
114
+ Timecop.freeze do
115
+ expect do
116
+ worker.work
117
+ end.to change_queue_size_of(job_type).by(-1)
118
+
119
+ failed_queue.count.should == 0 # not enqueued on Failed
120
+ Jobs::Tests::LockedTestJob.should have_scheduled(true).at(Time.now + described_class::BACKOFF_WIDTH.seconds.to_i) # should have scheduled a retry for later
121
+
122
+ expect do
123
+ job_type.enqueue_perform(true)
124
+ end.to change_queue_size_of(job_type).by(0)
125
+ end
126
+ end
127
+ end
128
+ end
129
+
metadata ADDED
@@ -0,0 +1,173 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: queue_classic_plus
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.2
5
+ platform: ruby
6
+ authors:
7
+ - Simon Mathieu
8
+ - Russell Smith
9
+ - Jean-Philippe Boily
10
+ autorequire:
11
+ bindir: bin
12
+ cert_chain: []
13
+ date: 2014-10-29 00:00:00.000000000 Z
14
+ dependencies:
15
+ - !ruby/object:Gem::Dependency
16
+ name: queue_classic
17
+ requirement: !ruby/object:Gem::Requirement
18
+ requirements:
19
+ - - ">="
20
+ - !ruby/object:Gem::Version
21
+ version: '0'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '0'
29
+ - !ruby/object:Gem::Dependency
30
+ name: queue_classic-later
31
+ requirement: !ruby/object:Gem::Requirement
32
+ requirements:
33
+ - - ">="
34
+ - !ruby/object:Gem::Version
35
+ version: '0'
36
+ type: :runtime
37
+ prerelease: false
38
+ version_requirements: !ruby/object:Gem::Requirement
39
+ requirements:
40
+ - - ">="
41
+ - !ruby/object:Gem::Version
42
+ version: '0'
43
+ - !ruby/object:Gem::Dependency
44
+ name: activerecord
45
+ requirement: !ruby/object:Gem::Requirement
46
+ requirements:
47
+ - - ">"
48
+ - !ruby/object:Gem::Version
49
+ version: '3.0'
50
+ type: :runtime
51
+ prerelease: false
52
+ version_requirements: !ruby/object:Gem::Requirement
53
+ requirements:
54
+ - - ">"
55
+ - !ruby/object:Gem::Version
56
+ version: '3.0'
57
+ - !ruby/object:Gem::Dependency
58
+ name: activesupport
59
+ requirement: !ruby/object:Gem::Requirement
60
+ requirements:
61
+ - - ">"
62
+ - !ruby/object:Gem::Version
63
+ version: '3.0'
64
+ type: :runtime
65
+ prerelease: false
66
+ version_requirements: !ruby/object:Gem::Requirement
67
+ requirements:
68
+ - - ">"
69
+ - !ruby/object:Gem::Version
70
+ version: '3.0'
71
+ - !ruby/object:Gem::Dependency
72
+ name: with_advisory_lock
73
+ requirement: !ruby/object:Gem::Requirement
74
+ requirements:
75
+ - - ">="
76
+ - !ruby/object:Gem::Version
77
+ version: '0'
78
+ type: :runtime
79
+ prerelease: false
80
+ version_requirements: !ruby/object:Gem::Requirement
81
+ requirements:
82
+ - - ">="
83
+ - !ruby/object:Gem::Version
84
+ version: '0'
85
+ - !ruby/object:Gem::Dependency
86
+ name: bundler
87
+ requirement: !ruby/object:Gem::Requirement
88
+ requirements:
89
+ - - "~>"
90
+ - !ruby/object:Gem::Version
91
+ version: '1.6'
92
+ type: :development
93
+ prerelease: false
94
+ version_requirements: !ruby/object:Gem::Requirement
95
+ requirements:
96
+ - - "~>"
97
+ - !ruby/object:Gem::Version
98
+ version: '1.6'
99
+ - !ruby/object:Gem::Dependency
100
+ name: rake
101
+ requirement: !ruby/object:Gem::Requirement
102
+ requirements:
103
+ - - ">="
104
+ - !ruby/object:Gem::Version
105
+ version: '0'
106
+ type: :development
107
+ prerelease: false
108
+ version_requirements: !ruby/object:Gem::Requirement
109
+ requirements:
110
+ - - ">="
111
+ - !ruby/object:Gem::Version
112
+ version: '0'
113
+ description: ''
114
+ email:
115
+ - simon.math@gmail.com
116
+ - russ@rainforestqa.com
117
+ - j@jipi.ca
118
+ executables: []
119
+ extensions: []
120
+ extra_rdoc_files: []
121
+ files:
122
+ - ".gitignore"
123
+ - ".rspec"
124
+ - ".rvmrc"
125
+ - Gemfile
126
+ - LICENSE.txt
127
+ - README.md
128
+ - Rakefile
129
+ - lib/queue_classic_plus.rb
130
+ - lib/queue_classic_plus/base.rb
131
+ - lib/queue_classic_plus/metrics.rb
132
+ - lib/queue_classic_plus/railtie.rb
133
+ - lib/queue_classic_plus/tasks/work.rake
134
+ - lib/queue_classic_plus/update_metrics.rb
135
+ - lib/queue_classic_plus/version.rb
136
+ - lib/queue_classic_plus/worker.rb
137
+ - lib/rails/generators/qc_plus_job/qc_plus_job_generator.rb
138
+ - lib/rails/generators/qc_plus_job/templates/job.rb.erb
139
+ - lib/rails/generators/qc_plus_job/templates/job_spec.rb.erb
140
+ - queue_classic_plus.gemspec
141
+ - spec/base_spec.rb
142
+ - spec/sample_jobs.rb
143
+ - spec/spec_helper.rb
144
+ - spec/worker_spec.rb
145
+ homepage: ''
146
+ licenses:
147
+ - MIT
148
+ metadata: {}
149
+ post_install_message:
150
+ rdoc_options: []
151
+ require_paths:
152
+ - lib
153
+ required_ruby_version: !ruby/object:Gem::Requirement
154
+ requirements:
155
+ - - ">="
156
+ - !ruby/object:Gem::Version
157
+ version: '0'
158
+ required_rubygems_version: !ruby/object:Gem::Requirement
159
+ requirements:
160
+ - - ">="
161
+ - !ruby/object:Gem::Version
162
+ version: '0'
163
+ requirements: []
164
+ rubyforge_project:
165
+ rubygems_version: 2.2.2
166
+ signing_key:
167
+ specification_version: 4
168
+ summary: Useful extras for Queue Classic
169
+ test_files:
170
+ - spec/base_spec.rb
171
+ - spec/sample_jobs.rb
172
+ - spec/spec_helper.rb
173
+ - spec/worker_spec.rb