queue_classic_plus 0.0.2 → 1.0.0.alpha2

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: 8acdaf6cd41362f50d7ddb9c50fa87555c00adc9
4
- data.tar.gz: 58c26d31a05c2390504975b6a7513179f0397ede
3
+ metadata.gz: f5b1934a03303e576a999e71f978a68da13f0298
4
+ data.tar.gz: d1de5250aa7b2ddfe4114043d87e5a95986145f8
5
5
  SHA512:
6
- metadata.gz: 453a051c64c4c539f488f9ea7f0c01da9ac326de11891746b6b0abea14bc4103bb347afd4020bedb0b37c0262d1c1a983659f39c0b35909521db6af2951dcacb
7
- data.tar.gz: 37263c41d6929a351e74920a8abe5d2d4d3bbc3c5c9bdb941f4cd2e6758ef2443ddf580dd16298124809ca0232ce7df02b3533fac75fef8fa911f21c45ecb597
6
+ metadata.gz: ebadc9325c0307789f6a6216047a2c2cfe240d4364b613ee9eefc9a479224a9196813146b90ed251124cd5ad7a4966556c8fca8e1b48a490cfd806bf95124ff3
7
+ data.tar.gz: 619c736ed7b74f94841328acfa27926e2799fecc6589974d9b79de236c1f396ef126d1e80205df223e626536b69c1c0a834014e09e190b000635e571c2a8e677
@@ -0,0 +1,8 @@
1
+ language: ruby
2
+ before_script:
3
+ - psql -c 'create database queue_classic_plus_test;' -U postgres
4
+
5
+ rvm:
6
+ - 2.0.0
7
+ - 2.1.5
8
+ - 2.2.0
data/Gemfile CHANGED
@@ -3,9 +3,15 @@ source 'https://rubygems.org'
3
3
  # Specify your gem's dependencies in queue_classic_plus.gemspec
4
4
  gemspec
5
5
 
6
+ gem "queue_classic_matchers", github: 'rainforestapp/queue_classic_matchers'
7
+ gem 'pry'
6
8
 
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'
9
+ group :development do
10
+ gem "guard-rspec", require: false
11
+ gem "terminal-notifier-guard"
12
+ end
13
+
14
+ group :test do
15
+ gem 'rspec'
16
+ gem 'timecop'
17
+ end
@@ -0,0 +1,77 @@
1
+ # A sample Guardfile
2
+ # More info at https://github.com/guard/guard#readme
3
+
4
+ ## Uncomment and set this to only include directories you want to watch
5
+ # directories %w(app lib config test spec feature)
6
+
7
+ ## Uncomment to clear the screen before every task
8
+ # clearing :on
9
+
10
+ ## Guard internally checks for changes in the Guardfile and exits.
11
+ ## If you want Guard to automatically start up again, run guard in a
12
+ ## shell loop, e.g.:
13
+ ##
14
+ ## $ while bundle exec guard; do echo "Restarting Guard..."; done
15
+ ##
16
+ ## Note: if you are using the `directories` clause above and you are not
17
+ ## watching the project directory ('.'), the you will want to move the Guardfile
18
+ ## to a watched dir and symlink it back, e.g.
19
+ #
20
+ # $ mkdir config
21
+ # $ mv Guardfile config/
22
+ # $ ln -s config/Guardfile .
23
+ #
24
+ # and, you'll have to watch "config/Guardfile" instead of "Guardfile"
25
+
26
+ # Note: The cmd option is now required due to the increasing number of ways
27
+ # rspec may be run, below are examples of the most common uses.
28
+ # * bundler: 'bundle exec rspec'
29
+ # * bundler binstubs: 'bin/rspec'
30
+ # * spring: 'bin/rspec' (This will use spring if running and you have
31
+ # installed the spring binstubs per the docs)
32
+ # * zeus: 'zeus rspec' (requires the server to be started separately)
33
+ # * 'just' rspec: 'rspec'
34
+
35
+ guard :rspec, cmd: "bundle exec rspec" do
36
+ require "guard/rspec/dsl"
37
+ dsl = Guard::RSpec::Dsl.new(self)
38
+
39
+ # Feel free to open issues for suggestions and improvements
40
+
41
+ # RSpec files
42
+ rspec = dsl.rspec
43
+ watch(rspec.spec_helper) { rspec.spec_dir }
44
+ watch(rspec.spec_support) { rspec.spec_dir }
45
+ watch(rspec.spec_files)
46
+
47
+ # Ruby files
48
+ ruby = dsl.ruby
49
+ dsl.watch_spec_files_for(ruby.lib_files)
50
+
51
+ # Rails files
52
+ rails = dsl.rails(view_extensions: %w(erb haml slim))
53
+ dsl.watch_spec_files_for(rails.app_files)
54
+ dsl.watch_spec_files_for(rails.views)
55
+
56
+ watch(rails.controllers) do |m|
57
+ [
58
+ rspec.spec.("routing/#{m[1]}_routing"),
59
+ rspec.spec.("controllers/#{m[1]}_controller"),
60
+ rspec.spec.("acceptance/#{m[1]}")
61
+ ]
62
+ end
63
+
64
+ # Rails config changes
65
+ watch(rails.spec_helper) { rspec.spec_dir }
66
+ watch(rails.routes) { "#{rspec.spec_dir}/routing" }
67
+ watch(rails.app_controller) { "#{rspec.spec_dir}/controllers" }
68
+
69
+ # Capybara features specs
70
+ watch(rails.view_dirs) { |m| rspec.spec.("features/#{m[1]}") }
71
+
72
+ # Turnip features and steps
73
+ watch(%r{^spec/acceptance/(.+)\.feature$})
74
+ watch(%r{^spec/acceptance/steps/(.+)_steps\.rb$}) do |m|
75
+ Dir[File.join("**/#{m[1]}.feature")][0] || "spec/acceptance"
76
+ end
77
+ end
data/README.md CHANGED
@@ -1,6 +1,8 @@
1
1
  # QueueClassicPlus
2
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.
3
+ [![Build Status](https://travis-ci.org/rainforestapp/queue_classic_plus.svg?branch=master)](https://travis-ci.org/rainforestapp/queue_classic_plus)
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.
4
6
 
5
7
  QueueClassicPlus adds many lacking features to QueueClassic.
6
8
 
@@ -10,13 +12,17 @@ QueueClassicPlus adds many lacking features to QueueClassic.
10
12
  - Metrics
11
13
  - Error logging / handling
12
14
  - Transactions
15
+ - Rails generator to create new jobs
16
+
17
+ ## Compatibility
18
+
19
+ This version of the matchers are compatible with queue_classic 3.1+ which includes built-in scheduling. See other branches for other compatible versions.
13
20
 
14
21
  ## Installation
15
22
 
16
23
  Add these line to your application's Gemfile:
17
24
 
18
25
  gem 'queue_classic_plus'
19
- gem "queue_classic-later", github: "dpiddy/queue_classic-later"
20
26
 
21
27
  And then execute:
22
28
 
@@ -33,7 +39,7 @@ Run the migration
33
39
  ### Create a new job
34
40
 
35
41
  ```bash
36
- rails g my_job test_job
42
+ rails g qc_plus_job test_job
37
43
  ```
38
44
 
39
45
  ```ruby
@@ -54,17 +60,67 @@ end
54
60
  Jobs::MyJob.do(1, "foo")
55
61
 
56
62
  # You can also schedule a job in the future by doing
57
-
58
63
  Jobs::MyJob.enqueue_perform_in(1.hour, 1, "foo")
59
64
  ```
60
65
 
61
66
  ### Run the QueueClassicPlus worker
62
67
 
68
+ QueueClassicPlus ships with its own worker and a rake task to run it. You need to use this worker to take advance of many features of QueueClassicPlus.
69
+
63
70
  ```
64
71
  QUEUE=low bundle exec qc_plus:work
65
72
  ```
66
73
 
67
- ## Advance configuration
74
+ ### Other jobs options
75
+
76
+ #### Singleton Job
77
+
78
+ It's common for background jobs to never need to be enqueed multiple time. QueueClassicPlus support these type of single jobs. Here's an example one:
79
+
80
+ ```ruby
81
+ class Jobs::UpdateMetrics < QueueClassicPlus::Base
82
+ @queue = :low
83
+
84
+ # Use the lock! keyword to prevent the job from being enqueud once.
85
+ lock!
86
+
87
+ def self.perform(metric_type)
88
+ # ...
89
+ end
90
+ end
91
+
92
+ ```
93
+
94
+ Note that `lock!` only prevents the same job from beeing enqued multiple times if the argument match.
95
+
96
+ So in our example:
97
+
98
+ ```ruby
99
+ Jobs::UpdateMetrics.do 'type_a' # enqueues job
100
+ Jobs::UpdateMetrics.do 'type_a' # does not enqueues job since it's already queued
101
+ Jobs::UpdateMetrics.do 'type_b' # enqueues job as the arguments are different.
102
+ ```
103
+
104
+ ```ruby
105
+ class Jobs::NoTransaction < QueueClassicPlus::Base
106
+ # Don't run the perform method in a transaction
107
+ skip_transaction!
108
+
109
+ @queue = :low
110
+
111
+ def self.perform(user_id)
112
+ # ...
113
+ end
114
+ end
115
+ ```
116
+
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
+ ## Advanced configuration
68
124
 
69
125
  If you want to log exceptions in your favorite exception tracker. You can configured it like sso:
70
126
 
@@ -84,14 +140,22 @@ QueueClassicPlus.update_metrics
84
140
 
85
141
  Call this is a cron job or something similar.
86
142
 
87
- ## TODO
143
+ If you are using NewRelic and want to push performance data to it, you can add this to an initializer:
88
144
 
89
- - Remove dep on ActiveRecord
145
+ ```ruby
146
+ require "queue_classic_plus/new_relic"
147
+ ```
90
148
 
91
149
  ## Contributing
92
150
 
93
151
  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
152
+ - Create your feature branch (`git checkout -b my-new-feature`)
153
+ - Commit your changes (`git commit -am 'Add some feature'`)
154
+ - Push to the branch (`git push origin my-new-feature`)
155
+ - Create a new Pull Request
156
+
157
+ ### Setting up the test database
158
+
159
+ ```
160
+ createdb queue_classic_plus_test
161
+ ```
data/Rakefile CHANGED
@@ -1,2 +1,11 @@
1
1
  require "bundler/gem_tasks"
2
2
 
3
+ begin
4
+ require 'rspec/core/rake_task'
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task :default => :spec
9
+ rescue LoadError
10
+ # no rspec available
11
+ end
@@ -1,14 +1,14 @@
1
+ require 'logger'
1
2
  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
3
 
7
4
  require "queue_classic_plus/version"
5
+ require "queue_classic_plus/inheritable_attr"
6
+ require "queue_classic_plus/inflector"
8
7
  require "queue_classic_plus/metrics"
9
8
  require "queue_classic_plus/update_metrics"
10
9
  require "queue_classic_plus/base"
11
10
  require "queue_classic_plus/worker"
11
+ require "queue_classic_plus/queue_classic/queue"
12
12
 
13
13
  module QueueClassicPlus
14
14
  require 'queue_classic_plus/railtie' if defined?(Rails)
@@ -17,14 +17,12 @@ module QueueClassicPlus
17
17
  conn = QC::ConnAdapter.new(c)
18
18
  conn.execute("ALTER TABLE queue_classic_jobs ADD COLUMN last_error TEXT")
19
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
20
  end
22
21
 
23
22
  def self.demigrate(c = QC::default_conn_adapter.connection)
24
23
  conn = QC::ConnAdapter.new(c)
25
24
  conn.execute("ALTER TABLE queue_classic_jobs DROP COLUMN last_error")
26
25
  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
26
  end
29
27
 
30
28
  def self.exception_handler
@@ -1,13 +1,15 @@
1
1
  module QueueClassicPlus
2
2
  class Base
3
+ extend QueueClassicPlus::InheritableAttribute
4
+
3
5
  def self.queue
4
6
  QC::Queue.new(@queue)
5
7
  end
6
8
 
7
- class_attribute :locked
8
- class_attribute :skip_transaction
9
- class_attribute :retries_on
10
- class_attribute :max_retries
9
+ inheritable_attr :locked
10
+ inheritable_attr :skip_transaction
11
+ inheritable_attr :retries_on
12
+ inheritable_attr :max_retries
11
13
 
12
14
  self.max_retries = 0
13
15
  self.retries_on = {}
@@ -39,35 +41,21 @@ module QueueClassicPlus
39
41
 
40
42
  def self.can_enqueue?(method, *args)
41
43
  if locked?
42
- lock_key = [@queue, method, args].to_json
43
44
  max_lock_time = ENV.fetch("QUEUE_CLASSIC_MAX_LOCK_TIME", 10 * 60).to_i
44
45
 
45
- ActiveRecord::Base.with_advisory_lock(lock_key, 0.1) do
46
- q = "SELECT COUNT(1) AS count
47
- FROM
46
+ q = "SELECT COUNT(1) AS count
47
+ FROM
48
48
  (
49
- (
50
49
  SELECT 1
51
50
  FROM queue_classic_jobs
52
51
  WHERE q_name = $1 AND method = $2 AND args::text = $3::text
53
52
  AND (locked_at IS NULL OR locked_at > current_timestamp - interval '#{max_lock_time} seconds')
54
53
  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
54
  )
66
- AS x"
55
+ AS x"
67
56
 
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
57
+ result = QC.default_conn_adapter.execute(q, @queue, method, args.to_json)
58
+ result['count'].to_i == 0
71
59
  else
72
60
  true
73
61
  end
@@ -89,7 +77,7 @@ module QueueClassicPlus
89
77
  end
90
78
 
91
79
  def self.restart_in(time, remaining_retries, *args)
92
- queue.enqueue_in_with_custom(time, "#{self.to_s}._perform", {'remaining_retries' => remaining_retries}, *args)
80
+ queue.enqueue_retry_in(time, "#{self.to_s}._perform", remaining_retries, *args)
93
81
  end
94
82
 
95
83
  def self.do(*args)
@@ -111,17 +99,36 @@ module QueueClassicPlus
111
99
  end
112
100
 
113
101
  def self.librato_key
114
- (self.name || "").underscore.gsub(/\//, ".")
102
+ Inflector.underscore(self.name || "").gsub(/\//, ".")
115
103
  end
116
104
 
117
105
  def self.transaction(options = {}, &block)
118
- ActiveRecord::Base.transaction(options, &block)
106
+ if defined?(ActiveRecord)
107
+ # If ActiveRecord is loaded, we use it's own transaction mechanisn since
108
+ # it has slightly different semanctics for rollback.
109
+ ActiveRecord::Base.transaction(options, &block)
110
+ else
111
+ begin
112
+ execute "BEGIN"
113
+ block.call
114
+ rescue
115
+ execute "ROLLBACK"
116
+ raise
117
+ end
118
+
119
+ execute "COMMIT"
120
+ end
119
121
  end
120
122
 
121
123
  # Debugging
122
124
  def self.list
123
125
  q = "SELECT * FROM queue_classic_jobs WHERE q_name = '#{@queue}'"
124
- ActiveRecord::Base.connection.execute(q).to_a
126
+ execute q
127
+ end
128
+
129
+ private
130
+ def self.execute(sql, *args)
131
+ QC.default_conn_adapter.execute(sql, *args)
125
132
  end
126
133
  end
127
134
  end
@@ -0,0 +1,15 @@
1
+ module QueueClassicPlus
2
+ module Inflector
3
+ # Storngly inspired by
4
+ # https://github.com/rails/rails/blob/master/activesupport/lib/active_support/inflector/methods.rb#L91
5
+ def self.underscore(camel_cased_word)
6
+ return camel_cased_word unless camel_cased_word =~ /[A-Z-]|::/
7
+ word = camel_cased_word.to_s.gsub(/::/, '/')
8
+ word.gsub!(/([A-Z\d]+)([A-Z][a-z])/,'\1_\2')
9
+ word.gsub!(/([a-z\d])([A-Z])/,'\1_\2')
10
+ word.tr!("-", "_")
11
+ word.downcase!
12
+ word
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,35 @@
1
+ module QueueClassicPlus
2
+ # From https://github.com/apotonick/uber/blob/master/lib/uber/inheritable_attr.rb which is MIT license
3
+ module InheritableAttribute
4
+ def inheritable_attr(name)
5
+ instance_eval %Q{
6
+ def #{name}=(v)
7
+ @#{name} = v
8
+ end
9
+ def #{name}
10
+ return @#{name} if instance_variable_defined?(:@#{name})
11
+ @#{name} = InheritableAttribute.inherit_for(self, :#{name})
12
+ end
13
+ }
14
+ end
15
+
16
+ def self.inherit_for(klass, name)
17
+ return unless klass.superclass.respond_to?(name)
18
+
19
+ value = klass.superclass.send(name) # could be nil.
20
+ Clone.(value) # this could be dynamic, allowing other inheritance strategies.
21
+ end
22
+
23
+ class Clone
24
+ # The second argument allows injecting more types.
25
+ def self.call(value, uncloneable=uncloneable())
26
+ uncloneable.each { |klass| return value if value.kind_of?(klass) }
27
+ value.clone
28
+ end
29
+
30
+ def self.uncloneable
31
+ [Symbol, TrueClass, FalseClass, NilClass]
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,35 @@
1
+ require 'new_relic/agent/method_tracer'
2
+
3
+ QueueClassicPlus::Base.class_eval do
4
+ class << self
5
+ include NewRelic::Agent::Instrumentation::ControllerInstrumentation
6
+
7
+ def new_relic_key
8
+ "Custom/QueueClassicPlus/#{librato_key}"
9
+ end
10
+
11
+ def _perform_with_new_relic(*args)
12
+ opts = {
13
+ name: 'perform',
14
+ class_name: self.name,
15
+ category: 'OtherTransaction/QueueClassicPlus',
16
+ }
17
+
18
+ perform_action_with_newrelic_trace(opts) do
19
+ if NewRelic::Agent.config[:'queue_classic_plus.capture_params']
20
+ NewRelic::Agent.add_custom_parameters(job_arguments: args)
21
+ end
22
+ _perform_without_new_relic *args
23
+ end
24
+ end
25
+
26
+ alias_method_chain :_perform, :new_relic
27
+ end
28
+ end
29
+
30
+ QueueClassicPlus::CustomWorker.class_eval do
31
+ def initialize(*)
32
+ ::NewRelic::Agent.manual_start
33
+ super
34
+ end
35
+ end
@@ -0,0 +1,11 @@
1
+ module QC
2
+ class Queue
3
+ def enqueue_retry_in(seconds, method, remaining_retries, *args)
4
+ QC.log_yield(:measure => 'queue.enqueue') do
5
+ s = "INSERT INTO #{TABLE_NAME} (q_name, method, args, scheduled_at, remaining_retries)
6
+ 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
+ end
9
+ end
10
+ end
11
+ end
@@ -1,62 +1,83 @@
1
1
  module QueueClassicPlus
2
2
  module UpdateMetrics
3
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'))
4
+ metrics.each do |name, values|
5
+ if values.respond_to?(:each)
6
+ values.each do |hash|
7
+ hash.to_a.each do |(source, count)|
8
+ Metrics.measure("qc.#{name}", count, source: source)
9
+ end
10
+ end
11
+ else
12
+ Metrics.measure("qc.#{name}", values)
15
13
  end
16
14
  end
15
+ end
17
16
 
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
17
+ def self.metrics
18
+ {
19
+ jobs_queued: jobs_queued,
20
+ jobs_scheduled: jobs_scheduled,
21
+ max_created_at: max_age("created_at", "created_at = scheduled_at"),
22
+ max_locked_at: max_age("locked_at", "locked_at IS NOT NULL"),
23
+ "max_created_at.unlocked" => max_age("locked_at", "locked_at IS NULL"),
24
+ "jobs_delayed.lag" => max_age("scheduled_at"),
25
+ "jobs_delayed.late_count" => late_count,
26
+ }
27
+ end
23
28
 
24
- # Log oldes unlocked jobs
25
- age = max_age("created_at", "locked_at IS NULL")
26
- Metrics.measure("qc.max_created_at.unlocked", age)
29
+ def self.jobs_queued
30
+ sql_group_count "SELECT q_name AS group, COUNT(1)
31
+ FROM queue_classic_jobs
32
+ WHERE scheduled_at <= NOW() GROUP BY q_name"
33
+ end
27
34
 
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
35
+ def self.jobs_scheduled
36
+ sql_group_count "SELECT q_name AS group, COUNT(1)
37
+ FROM queue_classic_jobs
38
+ WHERE scheduled_at > NOW() GROUP BY q_name"
39
+ end
32
40
 
33
- Metrics.measure("qc.jobs_delayed.lag", lag.to_f)
41
+ def self.late_count
42
+ nb_late = execute("SELECT COUNT(1)
43
+ FROM queue_classic_jobs
44
+ WHERE scheduled_at < NOW() AND #{not_failed}")
45
+ nb_late ? Integer(nb_late['count']) : 0
46
+ end
34
47
 
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
48
+ private
39
49
 
40
- Metrics.measure("qc.jobs_delayed.late_count", nb_late.to_i)
50
+ def self.sql_group_count(sql)
51
+ results = execute(sql)
52
+ results = [results] if Hash === results
53
+ Array(results).map do |h|
54
+ {
55
+ h.fetch("group") => Integer(h.fetch('count'))
56
+ }
41
57
  end
42
58
  end
43
59
 
44
- private
45
60
  def self.max_age(column, *conditions)
46
- conditions.unshift "q_name != '#{::QueueClassicPlus::CustomWorker::FailedQueue.name}'"
61
+ conditions.unshift not_failed
62
+ conditions.unshift "scheduled_at <= NOW()"
47
63
 
48
- q = "SELECT EXTRACT(EPOCH FROM now() - #{column}) AS age_in_seconds
64
+ q = "SELECT EXTRACT(EPOCH FROM now() - #{column}) AS age_in_seconds
49
65
  FROM queue_classic_jobs
50
66
  WHERE #{conditions.join(" AND ")}
51
67
  ORDER BY age_in_seconds DESC
68
+ LIMIT 1
52
69
  "
53
- age_info = execute(q).first
70
+ age_info = execute(q)
54
71
 
55
72
  age_info ? age_info['age_in_seconds'].to_i : 0
56
73
  end
57
74
 
58
75
  def self.execute(q)
59
- ActiveRecord::Base.connection.execute(q)
76
+ QC.default_conn_adapter.execute(q)
77
+ end
78
+
79
+ def self.not_failed
80
+ "q_name != '#{::QueueClassicPlus::CustomWorker::FailedQueue.name}'"
60
81
  end
61
82
  end
62
83
  end
@@ -1,3 +1,3 @@
1
1
  module QueueClassicPlus
2
- VERSION = "0.0.2"
2
+ VERSION = "1.0.0.alpha2"
3
3
  end
@@ -1,18 +1,12 @@
1
1
  module QueueClassicPlus
2
2
  class CustomWorker < QC::Worker
3
- class QueueClassicJob < ActiveRecord::Base
4
- end
5
-
6
3
  BACKOFF_WIDTH = 10
7
4
  FailedQueue = QC::Queue.new("failed_jobs")
8
5
 
9
6
  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
7
+ sql = "INSERT INTO #{QC::TABLE_NAME} (q_name, method, args, last_error) VALUES ('failed_jobs', $1, $2, $3)"
8
+ last_error = e.backtrace ? ([e.message] + e.backtrace ).join("\n") : e.message
9
+ QC.default_conn_adapter.execute sql, job[:method], JSON.dump(job[:args]), last_error
16
10
 
17
11
  QueueClassicPlus.exception_handler.call(e, job)
18
12
  Metrics.increment("qc.errors", source: @q_name)
@@ -22,11 +16,9 @@ module QueueClassicPlus
22
16
  QueueClassicPlus.logger.info "Handling exception #{e.message} for job #{job[:id]}"
23
17
  klass = job_klass(job)
24
18
 
25
- model = QueueClassicJob.find(job[:id])
26
-
27
19
  # The mailers doesn't have a retries_on?
28
20
  if klass && klass.respond_to?(:retries_on?) && klass.retries_on?(e)
29
- remaining_retries = model.remaining_retries || klass.max_retries
21
+ remaining_retries = job[:remaining_retries] || klass.max_retries
30
22
  remaining_retries -= 1
31
23
 
32
24
  if remaining_retries > 0
@@ -39,7 +31,8 @@ module QueueClassicPlus
39
31
  else
40
32
  enqueue_failed(job, e)
41
33
  end
42
- model.destroy
34
+
35
+ FailedQueue.delete(job[:id])
43
36
  end
44
37
 
45
38
  private
@@ -18,11 +18,7 @@ Gem::Specification.new do |spec|
18
18
  spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
19
19
  spec.require_paths = ["lib"]
20
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"
21
+ spec.add_dependency "queue_classic", ">= 3.1.0"
26
22
  spec.add_development_dependency "bundler", "~> 1.6"
27
23
  spec.add_development_dependency "rake"
28
24
  end
@@ -17,9 +17,8 @@ describe QueueClassicPlus::Base do
17
17
 
18
18
  it "does allow multiple enqueues if something got locked for too long" do
19
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
- "
20
+ one_day_ago = Time.now - 60*60*24
21
+ execute "UPDATE queue_classic_jobs SET locked_at = '#{one_day_ago}' WHERE q_name = 'test'"
23
22
  subject.do
24
23
  subject.should have_queue_size_of(2)
25
24
  end
@@ -40,8 +39,8 @@ describe QueueClassicPlus::Base do
40
39
  end
41
40
 
42
41
  it "calls perform in a transaction" do
43
- ActiveRecord::Base.should_receive(:transaction).and_call_original
44
- subject._perform
42
+ QueueClassicPlus::Base.should_receive(:transaction).and_call_original
43
+ subject._perform
45
44
  end
46
45
 
47
46
  it "measures the time" do
@@ -62,8 +61,8 @@ describe QueueClassicPlus::Base do
62
61
  end
63
62
 
64
63
  it "calls perform outside of a transaction" do
65
- ActiveRecord::Base.should_not_receive(:transaction)
66
- subject._perform
64
+ QueueClassicPlus::Base.should_not_receive(:transaction)
65
+ subject._perform
67
66
  end
68
67
  end
69
68
 
@@ -0,0 +1,9 @@
1
+ module QcHelpers
2
+ def execute(sql, *args)
3
+ QC.default_conn_adapter.execute(sql, *args)
4
+ end
5
+
6
+ def find_job(id)
7
+ execute("SELECT * FROM #{QC::TABLE_NAME} WHERE id = $1", id)
8
+ end
9
+ end
@@ -0,0 +1,18 @@
1
+ require 'spec_helper'
2
+
3
+ module QueueClassicPlus
4
+ describe Inflector do
5
+ describe ".underscore" do
6
+ {
7
+ "foo" => "foo",
8
+ "Foo" => "foo",
9
+ "FooBar" => "foo_bar",
10
+ "Foo::Bar" => "foo/bar"
11
+ }.each do |word, expected|
12
+ it "converst #{word} to #{expected}" do
13
+ expect(described_class.underscore(word)).to eq(expected)
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -3,29 +3,22 @@ require 'pg'
3
3
  require 'timecop'
4
4
  require 'queue_classic_matchers'
5
5
  require_relative './sample_jobs'
6
+ require_relative './helpers'
7
+ require 'pry'
8
+
9
+ ENV["DATABASE_URL"] ||= "postgres:///queue_classic_plus_test"
6
10
 
7
11
  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
- )
12
+ config.include QcHelpers
15
13
 
16
- ActiveRecord::Base.connection.execute "drop schema public cascade; create schema public;"
14
+ config.before(:suite) do
15
+ QC.default_conn_adapter.execute "drop schema public cascade; create schema public;"
17
16
 
18
- QC.default_conn_adapter = QC::ConnAdapter.new(ActiveRecord::Base.connection.raw_connection)
19
17
  QC::Setup.create
20
- QC::Later::Setup.create
21
18
  QueueClassicPlus.migrate
22
19
  end
23
20
 
24
21
  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
-
22
+ QC.default_conn_adapter.execute "TRUNCATE queue_classic_jobs;"
30
23
  end
31
24
  end
@@ -0,0 +1,125 @@
1
+ require 'spec_helper'
2
+
3
+ describe QueueClassicPlus::UpdateMetrics do
4
+ describe ".update" do
5
+ it "works" do
6
+ QC.enqueue "puts"
7
+
8
+ expect(QueueClassicPlus::Metrics).to(receive(:measure)).at_least(1).times do |metric, value, options|
9
+ expect(metric).to_not be_nil
10
+ expect(value).to_not be_nil
11
+ end
12
+ described_class.update
13
+ end
14
+ end
15
+
16
+ describe ".metrics" do
17
+ subject { described_class.metrics }
18
+
19
+ before do
20
+ QC.enqueue "puts"
21
+ QC.enqueue "puts", 2
22
+ QC.enqueue_in 60, "puts", 2
23
+ end
24
+
25
+ context "jobs_queued" do
26
+ it "returns the number of jobs group per queue" do
27
+ expect(subject[:jobs_queued]).to eq([{"default" => 2}])
28
+ end
29
+ end
30
+
31
+ context "jobs_scheduled" do
32
+ it "returns the number of jobs group per queue" do
33
+ expect(subject[:jobs_scheduled]).to eq([{"default" => 1}])
34
+ end
35
+ end
36
+
37
+ context "max_locked_at" do
38
+ it "zero if nothing is locked" do
39
+ max = subject[:max_locked_at]
40
+ expect(max).to eq(0)
41
+ end
42
+
43
+ it "returns the age of the oldest lock" do
44
+ execute "UPDATE queue_classic_jobs SET locked_at = '#{Time.now - 60}'"
45
+
46
+ max = subject[:max_locked_at]
47
+ expect(59..61).to include(max)
48
+ end
49
+
50
+ context 'scheduled jobs' do
51
+ it 'reports the correct max_locked_at' do
52
+ execute "UPDATE queue_classic_jobs SET locked_at = '#{Time.now - 30}', scheduled_at = '#{Time.now - 60}', created_at = '#{Time.now - 5*60}'"
53
+
54
+ expect(subject[:max_locked_at]).to be_within(1).of(30)
55
+ end
56
+ end
57
+ end
58
+
59
+ context "max_created_at" do
60
+ it "returns a small positive value" do
61
+ max = subject[:max_created_at]
62
+ expect(0..0.2).to include(max)
63
+ end
64
+
65
+ context 'scheduled jobs' do
66
+ it "ignores jobs schedule in the future" do
67
+ execute "UPDATE queue_classic_jobs SET created_at = '#{Time.now - 60}', scheduled_at = '#{Time.now + 60}'"
68
+
69
+ max = subject[:max_created_at]
70
+ expect(0..0.2).to include(max)
71
+ end
72
+
73
+ it 'reports time only for jobs that were never scheduled for future' do
74
+ execute "DELETE FROM queue_classic_jobs"
75
+ QC.enqueue 'puts'
76
+ QC.enqueue_in 1, 'puts'
77
+ one_min_ago = Time.now - 60
78
+ execute "UPDATE queue_classic_jobs SET created_at = '#{one_min_ago}', scheduled_at = '#{one_min_ago}'"
79
+ expect(subject[:max_created_at]).to be_within(1).of(60)
80
+ end
81
+ end
82
+ end
83
+
84
+ context "max_created_at.unlocked" do
85
+ it "ignores lock jobs" do
86
+ execute "UPDATE queue_classic_jobs SET locked_at = '#{Time.now - 60}', created_at = '#{Time.now - 2*60}'"
87
+
88
+ max = subject["max_created_at.unlocked"]
89
+ expect(max).to eq(0)
90
+ end
91
+ end
92
+
93
+ context "jobs_delayed.lag" do
94
+ it "returns the maximum different between the scheduled time and now" do
95
+ execute "UPDATE queue_classic_jobs SET scheduled_at = '#{Time.now - 60}'"
96
+
97
+ lag = subject["jobs_delayed.lag"]
98
+ expect(lag).to be_within(1.0).of(60)
99
+ end
100
+
101
+ it "ignores jobs scheduled in the future" do
102
+ execute "UPDATE queue_classic_jobs SET scheduled_at = '#{Time.now + 60}'"
103
+
104
+ lag = subject["jobs_delayed.lag"]
105
+ expect(lag).to eq(0)
106
+ end
107
+
108
+ it "ignores the failed queue" do
109
+ execute "UPDATE queue_classic_jobs SET scheduled_at = '#{Time.now - 60}', q_name = 'failed_jobs'"
110
+
111
+ lag = subject["jobs_delayed.lag"]
112
+ expect(lag).to eq(0)
113
+ end
114
+ end
115
+
116
+ context "jobs_delayed.late_count" do
117
+ it "returns the jobs that a created in the future" do
118
+ count = subject["jobs_delayed.late_count"]
119
+ # All jobs are always late because enqueue sets the value of
120
+ # scheduled_at to now() for normal jobs
121
+ expect(count).to eq(2)
122
+ end
123
+ end
124
+ end
125
+ end
@@ -1,12 +1,6 @@
1
1
  require 'spec_helper'
2
2
 
3
3
  describe QueueClassicPlus::CustomWorker do
4
- class QueueClassicLaterJob < ActiveRecord::Base
5
- end
6
-
7
- class QueueClassicJob < ActiveRecord::Base
8
- end
9
-
10
4
  let(:failed_queue) { described_class::FailedQueue }
11
5
 
12
6
  context "failure" do
@@ -21,7 +15,9 @@ describe QueueClassicPlus::CustomWorker do
21
15
  job = failed_queue.lock
22
16
  job[:method].should == "Kerklfadsjflaksj"
23
17
  job[:args].should == [1, 2, 3]
24
- QueueClassicJob.last.last_error.should be_present
18
+ full_job = find_job(job[:id])
19
+
20
+ full_job['last_error'].should_not be_nil
25
21
  end
26
22
 
27
23
  it "records normal errors" do
@@ -35,7 +31,7 @@ describe QueueClassicPlus::CustomWorker do
35
31
  context "retry" do
36
32
  let(:job_type) { Jobs::Tests::LockedTestJob }
37
33
  let(:worker) { described_class.new q_name: job_type.queue.name }
38
- let(:enqueue_expected_ts) { described_class::BACKOFF_WIDTH.seconds.from_now }
34
+ let(:enqueue_expected_ts) { Time.now + described_class::BACKOFF_WIDTH }
39
35
 
40
36
  before do
41
37
  job_type.skip_transaction!
@@ -51,16 +47,14 @@ describe QueueClassicPlus::CustomWorker do
51
47
  QueueClassicMatchers::QueueClassicRspec.find_by_args('low', 'Jobs::Tests::LockedTestJob._perform', [true]).first['remaining_retries'].should be_nil
52
48
 
53
49
  Timecop.freeze do
54
- expect do
55
- worker.work
56
- end.to change_queue_size_of(job_type).by(-1)
50
+ worker.work
57
51
 
58
52
  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
53
+ QueueClassicMatchers::QueueClassicRspec.find_by_args('low', 'Jobs::Tests::LockedTestJob._perform', [true]).first['remaining_retries'].should eq "4"
54
+ Jobs::Tests::LockedTestJob.should have_scheduled(true).at(Time.now + described_class::BACKOFF_WIDTH) # should have scheduled a retry for later
60
55
  end
61
56
 
62
- Timecop.freeze(Time.now + (described_class::BACKOFF_WIDTH.seconds.to_i * 2)) do
63
- QC::Later.tick(true)
57
+ Timecop.freeze(Time.now + (described_class::BACKOFF_WIDTH * 2)) do
64
58
  # the job should be re-enqueued with a decremented retry count
65
59
  jobs = QueueClassicMatchers::QueueClassicRspec.find_by_args('low', 'Jobs::Tests::LockedTestJob._perform', [true])
66
60
  jobs.size.should == 1
@@ -82,46 +76,11 @@ describe QueueClassicPlus::CustomWorker do
82
76
  QueueClassicMatchers::QueueClassicRspec.find_by_args('low', 'Jobs::Tests::LockedTestJob._perform', [true]).first['remaining_retries'].should be_nil
83
77
 
84
78
  Timecop.freeze do
85
- expect do
86
- worker.work
87
- end.to change_queue_size_of(job_type).by(-1)
79
+ worker.work
88
80
 
81
+ QueueClassicMatchers::QueueClassicRspec.find_by_args('failed_jobs', 'Jobs::Tests::LockedTestJob._perform', [true]).first['remaining_retries'].should be_nil
89
82
  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)
83
+ Jobs::Tests::LockedTestJob.should_not have_scheduled(true).at(Time.now + described_class::BACKOFF_WIDTH) # should have scheduled a retry for later
125
84
  end
126
85
  end
127
86
  end
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: 0.0.2
4
+ version: 1.0.0.alpha2
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: 2014-10-29 00:00:00.000000000 Z
13
+ date: 2015-02-21 00:00:00.000000000 Z
14
14
  dependencies:
15
15
  - !ruby/object:Gem::Dependency
16
16
  name: queue_classic
@@ -18,70 +18,14 @@ dependencies:
18
18
  requirements:
19
19
  - - ">="
20
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'
21
+ version: 3.1.0
78
22
  type: :runtime
79
23
  prerelease: false
80
24
  version_requirements: !ruby/object:Gem::Requirement
81
25
  requirements:
82
26
  - - ">="
83
27
  - !ruby/object:Gem::Version
84
- version: '0'
28
+ version: 3.1.0
85
29
  - !ruby/object:Gem::Dependency
86
30
  name: bundler
87
31
  requirement: !ruby/object:Gem::Requirement
@@ -122,13 +66,19 @@ files:
122
66
  - ".gitignore"
123
67
  - ".rspec"
124
68
  - ".rvmrc"
69
+ - ".travis.yml"
125
70
  - Gemfile
71
+ - Guardfile
126
72
  - LICENSE.txt
127
73
  - README.md
128
74
  - Rakefile
129
75
  - lib/queue_classic_plus.rb
130
76
  - lib/queue_classic_plus/base.rb
77
+ - lib/queue_classic_plus/inflector.rb
78
+ - lib/queue_classic_plus/inheritable_attr.rb
131
79
  - lib/queue_classic_plus/metrics.rb
80
+ - lib/queue_classic_plus/new_relic.rb
81
+ - lib/queue_classic_plus/queue_classic/queue.rb
132
82
  - lib/queue_classic_plus/railtie.rb
133
83
  - lib/queue_classic_plus/tasks/work.rake
134
84
  - lib/queue_classic_plus/update_metrics.rb
@@ -139,8 +89,11 @@ files:
139
89
  - lib/rails/generators/qc_plus_job/templates/job_spec.rb.erb
140
90
  - queue_classic_plus.gemspec
141
91
  - spec/base_spec.rb
92
+ - spec/helpers.rb
93
+ - spec/inflector_spec.rb
142
94
  - spec/sample_jobs.rb
143
95
  - spec/spec_helper.rb
96
+ - spec/update_metrics_spec.rb
144
97
  - spec/worker_spec.rb
145
98
  homepage: ''
146
99
  licenses:
@@ -157,9 +110,9 @@ required_ruby_version: !ruby/object:Gem::Requirement
157
110
  version: '0'
158
111
  required_rubygems_version: !ruby/object:Gem::Requirement
159
112
  requirements:
160
- - - ">="
113
+ - - ">"
161
114
  - !ruby/object:Gem::Version
162
- version: '0'
115
+ version: 1.3.1
163
116
  requirements: []
164
117
  rubyforge_project:
165
118
  rubygems_version: 2.2.2
@@ -168,6 +121,9 @@ specification_version: 4
168
121
  summary: Useful extras for Queue Classic
169
122
  test_files:
170
123
  - spec/base_spec.rb
124
+ - spec/helpers.rb
125
+ - spec/inflector_spec.rb
171
126
  - spec/sample_jobs.rb
172
127
  - spec/spec_helper.rb
128
+ - spec/update_metrics_spec.rb
173
129
  - spec/worker_spec.rb