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 +4 -4
- data/.gitignore +1 -0
- data/.travis.yml +4 -0
- data/Gemfile +1 -0
- data/README.md +13 -9
- data/lib/queue_classic_plus.rb +1 -1
- data/lib/queue_classic_plus/base.rb +19 -3
- data/lib/queue_classic_plus/inheritable_attr.rb +3 -1
- data/lib/queue_classic_plus/metrics.rb +1 -1
- data/lib/queue_classic_plus/queue_classic/queue.rb +25 -2
- data/lib/queue_classic_plus/tasks/work.rake +11 -2
- data/lib/queue_classic_plus/version.rb +1 -1
- data/lib/queue_classic_plus/worker.rb +54 -11
- data/spec/queue_classic/queue_spec.rb +23 -0
- data/spec/sample_jobs.rb +27 -0
- data/spec/worker_spec.rb +56 -14
- metadata +7 -5
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 7d499c51f34d828e8abf2c5e405d82ea3ae49b19
|
4
|
+
data.tar.gz: 335713b0d79d1b54b9dc342a36c88c2008fccab2
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: a529d8ca6f1a179f186f6b57bc90543bec9607a2c87e700db996a87301bea7073b353880ade9796d93d733573112f5545ef1e22440c29156127bd11edeb87212
|
7
|
+
data.tar.gz: c23fde5d11939355a2c8a90a92001f336da6ecf3c422aca1bd11ebed5e55f019b0d8fc5720495ad65439a95b79c11f77cd00f4433533a8d7baf38b19f807fcc7
|
data/.gitignore
CHANGED
data/.travis.yml
CHANGED
data/Gemfile
CHANGED
data/README.md
CHANGED
@@ -2,11 +2,11 @@
|
|
2
2
|
|
3
3
|
[](https://travis-ci.org/rainforestapp/queue_classic_plus)
|
4
4
|
|
5
|
-
[
|
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
|
-
-
|
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:
|
data/lib/queue_classic_plus.rb
CHANGED
@@ -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 =
|
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
|
105
|
+
perform(*args)
|
93
106
|
else
|
94
107
|
transaction do
|
95
|
-
|
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
|
@@ -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
|
-
|
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.
|
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,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
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
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
|
data/spec/sample_jobs.rb
CHANGED
@@ -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
|
data/spec/worker_spec.rb
CHANGED
@@ -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
|
-
|
69
|
-
|
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
|
-
|
75
|
-
|
76
|
-
|
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
|
-
|
79
|
-
|
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
|
-
|
82
|
-
|
83
|
-
|
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
|
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:
|
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:
|
116
|
+
version: '0'
|
116
117
|
requirements: []
|
117
118
|
rubyforge_project:
|
118
|
-
rubygems_version: 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
|