queue_classic_plus 1.0.0.alpha2 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: f5b1934a03303e576a999e71f978a68da13f0298
4
- data.tar.gz: d1de5250aa7b2ddfe4114043d87e5a95986145f8
3
+ metadata.gz: 7d499c51f34d828e8abf2c5e405d82ea3ae49b19
4
+ data.tar.gz: 335713b0d79d1b54b9dc342a36c88c2008fccab2
5
5
  SHA512:
6
- metadata.gz: ebadc9325c0307789f6a6216047a2c2cfe240d4364b613ee9eefc9a479224a9196813146b90ed251124cd5ad7a4966556c8fca8e1b48a490cfd806bf95124ff3
7
- data.tar.gz: 619c736ed7b74f94841328acfa27926e2799fecc6589974d9b79de236c1f396ef126d1e80205df223e626536b69c1c0a834014e09e190b000635e571c2a8e677
6
+ metadata.gz: a529d8ca6f1a179f186f6b57bc90543bec9607a2c87e700db996a87301bea7073b353880ade9796d93d733573112f5545ef1e22440c29156127bd11edeb87212
7
+ data.tar.gz: c23fde5d11939355a2c8a90a92001f336da6ecf3c422aca1bd11ebed5e55f019b0d8fc5720495ad65439a95b79c11f77cd00f4433533a8d7baf38b19f807fcc7
data/.gitignore CHANGED
@@ -21,3 +21,4 @@ tmp
21
21
  *.a
22
22
  mkmf.log
23
23
  tags
24
+ .project
@@ -1,4 +1,7 @@
1
1
  language: ruby
2
+ before_install:
3
+ - gem update bundler
4
+ install: bundle install --without development
2
5
  before_script:
3
6
  - psql -c 'create database queue_classic_plus_test;' -U postgres
4
7
 
@@ -6,3 +9,4 @@ rvm:
6
9
  - 2.0.0
7
10
  - 2.1.5
8
11
  - 2.2.0
12
+ - 2.3.1
data/Gemfile CHANGED
@@ -12,6 +12,7 @@ group :development do
12
12
  end
13
13
 
14
14
  group :test do
15
+ gem 'rake'
15
16
  gem 'rspec'
16
17
  gem 'timecop'
17
18
  end
data/README.md CHANGED
@@ -2,11 +2,11 @@
2
2
 
3
3
  [![Build Status](https://travis-ci.org/rainforestapp/queue_classic_plus.svg?branch=master)](https://travis-ci.org/rainforestapp/queue_classic_plus)
4
4
 
5
- [QueueClassic](https://github.com/QueueClassic/queue_classic) 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.
5
+ [queue_classic](https://github.com/QueueClassic/queue_classic) is a simple Postgresql backed DB queue. However, it's a little too simple to use it as the main queueing system of a medium to large app. This was developed at [Rainforest QA](https://www.rainforestqa.com/).
6
6
 
7
7
  QueueClassicPlus adds many lacking features to QueueClassic.
8
8
 
9
- - Standard job format
9
+ - Standardized job format
10
10
  - Retry on specific exceptions
11
11
  - Singleton jobs
12
12
  - Metrics
@@ -101,11 +101,21 @@ Jobs::UpdateMetrics.do 'type_a' # does not enqueues job since it's already queue
101
101
  Jobs::UpdateMetrics.do 'type_b' # enqueues job as the arguments are different.
102
102
  ```
103
103
 
104
+ #### Transactions
105
+
106
+ By default, all QueueClassicPlus jobs are executed in a PostgreSQL
107
+ transaction. This decision was made because most jobs are usually
108
+ pretty small and it's preferable to have all the benefits of the
109
+ transaction. You can optionally specify a postgres statement timeout
110
+ (in seconds) for all transactions with the environment variable
111
+ `POSTGRES_STATEMENT_TIMEOUT`.
112
+
113
+ You can disable this feature on a per job basis in the following way:
114
+
104
115
  ```ruby
105
116
  class Jobs::NoTransaction < QueueClassicPlus::Base
106
117
  # Don't run the perform method in a transaction
107
118
  skip_transaction!
108
-
109
119
  @queue = :low
110
120
 
111
121
  def self.perform(user_id)
@@ -114,12 +124,6 @@ class Jobs::NoTransaction < QueueClassicPlus::Base
114
124
  end
115
125
  ```
116
126
 
117
- #### Transaction
118
-
119
- By default, all QueueClassicPlus jobs are executed in a PostgreSQL transaction. This decision was made because most jobs are usually pretty small and it's preferable to have all the benefits of the transaction.
120
-
121
- You can disable this feature on a per job basis in the follwing way:
122
-
123
127
  ## Advanced configuration
124
128
 
125
129
  If you want to log exceptions in your favorite exception tracker. You can configured it like sso:
@@ -26,7 +26,7 @@ module QueueClassicPlus
26
26
  end
27
27
 
28
28
  def self.exception_handler
29
- @exception_handler ||= -> (exception, job) { nil }
29
+ @exception_handler ||= ->(exception, job) { nil }
30
30
  end
31
31
 
32
32
  def self.exception_handler=(handler)
@@ -10,11 +10,16 @@ module QueueClassicPlus
10
10
  inheritable_attr :skip_transaction
11
11
  inheritable_attr :retries_on
12
12
  inheritable_attr :max_retries
13
+ inheritable_attr :disable_retries
13
14
 
14
- self.max_retries = 0
15
+ self.max_retries = 5
15
16
  self.retries_on = {}
17
+ self.disable_retries = false
16
18
 
17
19
  def self.retry!(on: RuntimeError, max: 5)
20
+ if self.disable_retries
21
+ raise 'retry! should not be used in conjuction with disable_retries!'
22
+ end
18
23
  Array(on).each {|e| self.retries_on[e] = true}
19
24
  self.max_retries = max
20
25
  end
@@ -23,6 +28,14 @@ module QueueClassicPlus
23
28
  self.retries_on[exception.class] || self.retries_on.keys.any? {|klass| exception.is_a? klass}
24
29
  end
25
30
 
31
+ def self.disable_retries!
32
+ unless self.retries_on.empty?
33
+ raise 'disable_retries! should not be enabled in conjunction with retry!'
34
+ end
35
+
36
+ self.disable_retries = true
37
+ end
38
+
26
39
  def self.lock!
27
40
  self.locked = true
28
41
  end
@@ -89,10 +102,13 @@ module QueueClassicPlus
89
102
  def self._perform(*args)
90
103
  Metrics.timing("qu_perform_time", source: librato_key) do
91
104
  if skip_transaction
92
- perform *args
105
+ perform(*args)
93
106
  else
94
107
  transaction do
95
- perform *args
108
+ # .to_i defaults to 0, which means no timeout in postgres
109
+ timeout = ENV['POSTGRES_STATEMENT_TIMEOUT'].to_i * 1000
110
+ execute "SET LOCAL statement_timeout = #{timeout}"
111
+ perform(*args)
96
112
  end
97
113
  end
98
114
  end
@@ -28,7 +28,9 @@ module QueueClassicPlus
28
28
  end
29
29
 
30
30
  def self.uncloneable
31
- [Symbol, TrueClass, FalseClass, NilClass]
31
+ tmp = [Symbol, TrueClass, FalseClass, NilClass]
32
+ tmp += [Fixnum, Bignum] if RUBY_VERSION < '2.4.0'
33
+ tmp
32
34
  end
33
35
  end
34
36
  end
@@ -7,7 +7,7 @@ module QueueClassicPlus
7
7
 
8
8
  class Metrics
9
9
  def self.timing(*args, &block)
10
- provider.timing *args, &block
10
+ provider.timing(*args, &block)
11
11
  end
12
12
 
13
13
  def self.increment(*args)
@@ -1,11 +1,34 @@
1
1
  module QC
2
2
  class Queue
3
+
3
4
  def enqueue_retry_in(seconds, method, remaining_retries, *args)
4
5
  QC.log_yield(:measure => 'queue.enqueue') do
5
6
  s = "INSERT INTO #{TABLE_NAME} (q_name, method, args, scheduled_at, remaining_retries)
6
7
  VALUES ($1, $2, $3, now() + interval '#{seconds.to_i} seconds', $4)"
7
- res = conn_adapter.execute(s, name, method, JSON.dump(args), remaining_retries)
8
+
9
+ conn_adapter.execute(s, name, method, JSON.dump(args), remaining_retries)
8
10
  end
9
11
  end
12
+
13
+ def lock
14
+ QC.log_yield(:measure => 'queue.lock') do
15
+ s = "SELECT * FROM lock_head($1, $2)"
16
+ if r = conn_adapter.execute(s, name, top_bound)
17
+ {}.tap do |job|
18
+ job[:id] = r["id"]
19
+ job[:q_name] = r["q_name"]
20
+ job[:method] = r["method"]
21
+ job[:args] = JSON.parse(r["args"])
22
+ job[:remaining_retries] = r["remaining_retries"]
23
+ if r["scheduled_at"]
24
+ job[:scheduled_at] = Time.parse(r["scheduled_at"])
25
+ ttl = Integer((Time.now - job[:scheduled_at]) * 1000)
26
+ QC.measure("time-to-lock=#{ttl}ms source=#{name}")
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+
10
33
  end
11
- end
34
+ end
@@ -2,13 +2,21 @@ namespace :qc_plus do
2
2
  desc "Start a new worker for the (default or $QUEUE) queue"
3
3
  task :work => :environment do
4
4
  puts "Starting up worker for queue #{ENV['QUEUE']}"
5
+
6
+ if defined? Raven
7
+ Raven.configure do |config|
8
+ # ActiveRecord::RecordNotFound is ignored by Raven by default,
9
+ # which shouldn't happen in background jobs.
10
+ config.excluded_exceptions = []
11
+ end
12
+ end
13
+
5
14
  @worker = QueueClassicPlus::CustomWorker.new
6
15
 
7
16
  trap('INT') do
8
17
  $stderr.puts("Received INT. Shutting down.")
9
18
  if !@worker.running
10
- $stderr.puts("Worker has stopped running. Exit.")
11
- exit(1)
19
+ $stderr.puts("Worker has already stopped running.")
12
20
  end
13
21
  @worker.stop
14
22
  end
@@ -19,5 +27,6 @@ namespace :qc_plus do
19
27
  end
20
28
 
21
29
  @worker.start
30
+ $stderr.puts 'Shut down successfully'
22
31
  end
23
32
  end
@@ -1,3 +1,3 @@
1
1
  module QueueClassicPlus
2
- VERSION = "1.0.0.alpha2"
2
+ VERSION = "1.0.0"
3
3
  end
@@ -1,5 +1,9 @@
1
+ require 'pg'
2
+ require 'queue_classic'
3
+
1
4
  module QueueClassicPlus
2
5
  class CustomWorker < QC::Worker
6
+ CONNECTION_ERRORS = [PG::UnableToSend, PG::ConnectionBad].freeze
3
7
  BACKOFF_WIDTH = 10
4
8
  FailedQueue = QC::Queue.new("failed_jobs")
5
9
 
@@ -14,20 +18,34 @@ module QueueClassicPlus
14
18
 
15
19
  def handle_failure(job, e)
16
20
  QueueClassicPlus.logger.info "Handling exception #{e.message} for job #{job[:id]}"
21
+
22
+ force_retry = false
23
+ if connection_error?(e)
24
+ # If we've got here, unfortunately ActiveRecord's rollback mechanism may
25
+ # not have kicked in yet and we might be in a failed transaction. To be
26
+ # *absolutely* sure the retry/failure gets enqueued, we do a rollback
27
+ # just in case (and if we're not in a transaction it will be a no-op).
28
+ QueueClassicPlus.logger.info "Reset connection for job #{job[:id]}"
29
+ @conn_adapter.connection.reset
30
+ @conn_adapter.execute 'ROLLBACK'
31
+
32
+ # We definitely want to retry because the connection was lost mid-task.
33
+ force_retry = true
34
+ end
35
+
17
36
  klass = job_klass(job)
18
37
 
38
+ if force_retry && !(klass.respond_to?(:disable_retries) && klass.disable_retries)
39
+ Metrics.increment("qc.force_retry", source: @q_name)
40
+
41
+ retry_with_remaining(klass, job, e, 0)
19
42
  # The mailers doesn't have a retries_on?
20
- if klass && klass.respond_to?(:retries_on?) && klass.retries_on?(e)
21
- remaining_retries = job[:remaining_retries] || klass.max_retries
22
- remaining_retries -= 1
23
-
24
- if remaining_retries > 0
25
- klass.restart_in((klass.max_retries - remaining_retries) * BACKOFF_WIDTH,
26
- remaining_retries,
27
- *job[:args])
28
- else
29
- enqueue_failed(job, e)
30
- end
43
+ elsif klass && klass.respond_to?(:retries_on?) && klass.retries_on?(e)
44
+ Metrics.increment("qc.retry", source: @q_name)
45
+
46
+ backoff = (max_retries(klass) - remaining_retries(klass, job)) * BACKOFF_WIDTH
47
+
48
+ retry_with_remaining(klass, job, e, backoff)
31
49
  else
32
50
  enqueue_failed(job, e)
33
51
  end
@@ -36,6 +54,25 @@ module QueueClassicPlus
36
54
  end
37
55
 
38
56
  private
57
+
58
+ def retry_with_remaining(klass, job, e, backoff)
59
+ @remaining_retries = remaining_retries(klass, job)
60
+
61
+ if @remaining_retries > 0
62
+ klass.restart_in(backoff, @remaining_retries, *job[:args])
63
+ else
64
+ enqueue_failed(job, e)
65
+ end
66
+ end
67
+
68
+ def max_retries(klass)
69
+ klass.respond_to?(:max_retries) ? klass.max_retries : 5
70
+ end
71
+
72
+ def remaining_retries(klass, job)
73
+ @remaining_retries ? @remaining_retries - 1 : (job[:remaining_retries] || max_retries(klass)).to_i - 1
74
+ end
75
+
39
76
  def job_klass(job)
40
77
  begin
41
78
  Object.const_get(job[:method].split('.')[0])
@@ -43,5 +80,11 @@ module QueueClassicPlus
43
80
  nil
44
81
  end
45
82
  end
83
+
84
+ def connection_error?(e)
85
+ CONNECTION_ERRORS.any? { |klass| e.kind_of? klass } ||
86
+ (e.respond_to?(:original_exception) &&
87
+ CONNECTION_ERRORS.any? { |klass| e.original_exception.kind_of? klass })
88
+ end
46
89
  end
47
90
  end
@@ -0,0 +1,23 @@
1
+ require 'spec_helper'
2
+
3
+ describe QC do
4
+
5
+ describe ".lock" do
6
+
7
+ context "lock" do
8
+
9
+ it "should lock the job with remaining_retries" do
10
+ QC.enqueue_retry_in(1, "puts", 5, 2)
11
+ sleep 1
12
+ job = QC.lock
13
+
14
+ expect(job[:q_name]).to eq("default")
15
+ expect(job[:method]).to eq("puts")
16
+ expect(job[:args][0]).to be(2)
17
+ expect(job[:remaining_retries]).to eq("5")
18
+ end
19
+ end
20
+
21
+ end
22
+
23
+ end
@@ -1,3 +1,5 @@
1
+ require 'pg'
2
+
1
3
  class SomeException < RuntimeError
2
4
  end
3
5
 
@@ -21,6 +23,7 @@ module Jobs
21
23
  class TestJobNoRetry < QueueClassicPlus::Base
22
24
  class Custom < RuntimeError
23
25
  end
26
+ disable_retries!
24
27
 
25
28
  @queue = :low
26
29
 
@@ -39,5 +42,29 @@ module Jobs
39
42
  end
40
43
  end
41
44
 
45
+ class Exception < RuntimeError
46
+ attr_reader :original_exception
47
+
48
+ def initialize(e)
49
+ @original_exception = e
50
+ end
51
+ end
52
+
53
+ class ConnectionReapedTestJob < QueueClassicPlus::Base
54
+ @queue = :low
55
+ retry! on: Exception, max: 5
56
+
57
+ def self.perform
58
+ raise Exception.new(PG::UnableToSend.new)
59
+ end
60
+ end
61
+
62
+ class UniqueViolationTestJob < QueueClassicPlus::Base
63
+ @queue = :low
64
+
65
+ def self.perform
66
+ raise Exception.new(PG::UniqueViolation.new)
67
+ end
68
+ end
42
69
  end
43
70
  end
@@ -46,6 +46,8 @@ describe QueueClassicPlus::CustomWorker do
46
46
  failed_queue.count.should == 0
47
47
  QueueClassicMatchers::QueueClassicRspec.find_by_args('low', 'Jobs::Tests::LockedTestJob._perform', [true]).first['remaining_retries'].should be_nil
48
48
 
49
+ QueueClassicPlus::Metrics.should_receive(:increment).with('qc.retry', source: nil )
50
+
49
51
  Timecop.freeze do
50
52
  worker.work
51
53
 
@@ -65,24 +67,64 @@ describe QueueClassicPlus::CustomWorker do
65
67
  end
66
68
  end
67
69
 
68
- it "enqueues in the failed queue when retries have been exhausted" do
69
- job_type.max_retries = 0
70
- expect do
71
- job_type.enqueue_perform(true)
72
- end.to change_queue_size_of(job_type).by(1)
70
+ context 'when PG connection reaped during a job' do
71
+ before { Jobs::Tests::ConnectionReapedTestJob.enqueue_perform }
73
72
 
74
- Jobs::Tests::LockedTestJob.should have_queue_size_of(1)
75
- failed_queue.count.should == 0
76
- QueueClassicMatchers::QueueClassicRspec.find_by_args('low', 'Jobs::Tests::LockedTestJob._perform', [true]).first['remaining_retries'].should be_nil
73
+ it 'retries' do
74
+ QueueClassicPlus::Metrics.should_receive(:increment).with('qc.force_retry', source: nil )
75
+ Timecop.freeze do
76
+ worker.work
77
+ expect(failed_queue.count).to eq 0
78
+ QueueClassicMatchers::QueueClassicRspec.find_by_args('low', 'Jobs::Tests::ConnectionReapedTestJob._perform', []).first['remaining_retries'].should eq "4"
79
+ end
80
+ end
77
81
 
78
- Timecop.freeze do
79
- worker.work
82
+ it 'ensures to rollback' do
83
+ allow(QC.default_conn_adapter).to receive(:execute).and_call_original
84
+ expect(QC.default_conn_adapter).to receive(:execute).with('ROLLBACK')
85
+ Timecop.freeze do
86
+ worker.work
87
+ end
88
+ end
89
+ end
80
90
 
81
- QueueClassicMatchers::QueueClassicRspec.find_by_args('failed_jobs', 'Jobs::Tests::LockedTestJob._perform', [true]).first['remaining_retries'].should be_nil
82
- failed_queue.count.should == 1 # not enqueued on Failed
83
- Jobs::Tests::LockedTestJob.should_not have_scheduled(true).at(Time.now + described_class::BACKOFF_WIDTH) # should have scheduled a retry for later
91
+ context 'with non-connection based PG jobs' do
92
+ before { Jobs::Tests::UniqueViolationTestJob.enqueue_perform }
93
+
94
+ it 'sends the job to the failed jobs queue' do
95
+ Timecop.freeze do
96
+ worker.work
97
+ end
98
+ expect(failed_queue.count).to eq 1
99
+ end
100
+ end
101
+
102
+ context 'when retries have been exhausted' do
103
+ before do
104
+ job_type.max_retries = 0
105
+ end
106
+
107
+ after do
108
+ job_type.max_retries = 5
109
+ end
110
+
111
+ it 'enqueues in the failed queue' do
112
+ expect do
113
+ job_type.enqueue_perform(true)
114
+ end.to change_queue_size_of(job_type).by(1)
115
+
116
+ Jobs::Tests::LockedTestJob.should have_queue_size_of(1)
117
+ failed_queue.count.should == 0
118
+ QueueClassicMatchers::QueueClassicRspec.find_by_args('low', 'Jobs::Tests::LockedTestJob._perform', [true]).first['remaining_retries'].should be_nil
119
+
120
+ Timecop.freeze do
121
+ worker.work
122
+
123
+ QueueClassicMatchers::QueueClassicRspec.find_by_args('failed_jobs', 'Jobs::Tests::LockedTestJob._perform', [true]).first['remaining_retries'].should be_nil
124
+ failed_queue.count.should == 1 # not enqueued on Failed
125
+ Jobs::Tests::LockedTestJob.should_not have_scheduled(true).at(Time.now + described_class::BACKOFF_WIDTH) # should have scheduled a retry for later
126
+ end
84
127
  end
85
128
  end
86
129
  end
87
130
  end
88
-
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: queue_classic_plus
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0.alpha2
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Simon Mathieu
@@ -10,7 +10,7 @@ authors:
10
10
  autorequire:
11
11
  bindir: bin
12
12
  cert_chain: []
13
- date: 2015-02-21 00:00:00.000000000 Z
13
+ date: 2018-04-18 00:00:00.000000000 Z
14
14
  dependencies:
15
15
  - !ruby/object:Gem::Dependency
16
16
  name: queue_classic
@@ -91,6 +91,7 @@ files:
91
91
  - spec/base_spec.rb
92
92
  - spec/helpers.rb
93
93
  - spec/inflector_spec.rb
94
+ - spec/queue_classic/queue_spec.rb
94
95
  - spec/sample_jobs.rb
95
96
  - spec/spec_helper.rb
96
97
  - spec/update_metrics_spec.rb
@@ -110,12 +111,12 @@ required_ruby_version: !ruby/object:Gem::Requirement
110
111
  version: '0'
111
112
  required_rubygems_version: !ruby/object:Gem::Requirement
112
113
  requirements:
113
- - - ">"
114
+ - - ">="
114
115
  - !ruby/object:Gem::Version
115
- version: 1.3.1
116
+ version: '0'
116
117
  requirements: []
117
118
  rubyforge_project:
118
- rubygems_version: 2.2.2
119
+ rubygems_version: 2.6.13
119
120
  signing_key:
120
121
  specification_version: 4
121
122
  summary: Useful extras for Queue Classic
@@ -123,6 +124,7 @@ test_files:
123
124
  - spec/base_spec.rb
124
125
  - spec/helpers.rb
125
126
  - spec/inflector_spec.rb
127
+ - spec/queue_classic/queue_spec.rb
126
128
  - spec/sample_jobs.rb
127
129
  - spec/spec_helper.rb
128
130
  - spec/update_metrics_spec.rb