queue_classic_plus 1.0.0.alpha2 → 1.0.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 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