queue_classic_plus 0.0.2

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: 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