good_job 1.1.3 → 1.2.3

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
  SHA256:
3
- metadata.gz: 44da5dd6eb7dee7e08319f7405e97f81e4223f8c88d48ba4a0512fa006f89e78
4
- data.tar.gz: fe3f5ca8a39b85b1aa4be77a819e910691223cf36e80c6ab337ff1b224a26e16
3
+ metadata.gz: 9ea00429e2b1515df6973f66c81d1827acdb4528e39ac811ece90a69fbd0b4f6
4
+ data.tar.gz: 9d3c7e104fda0d102789fc97fb716e5372c6e746d169fb521b84ecf13312ce24
5
5
  SHA512:
6
- metadata.gz: e6648e41c7ff99915716702651cd43fb7fffcff24528a9fb2d7e4a7913303d85d25c3b3f8ef54cc1b716a273836bc3c7fb2911750e1d99f216b2074b03f13ae8
7
- data.tar.gz: '063048f09b76b10d4f38beb5fcf390d972f0952ed4df7b1053e8078da2bad005b816e21271026b95f8eaef12b8e3e1ce0a4cecf2cf40f965e852a8e2e9854aac'
6
+ metadata.gz: 7f34e2681a4642c9c337dd76253950a46d1edbff21feb6940eace1fa99b63d3d919f1eb3b2ae56b90d06d0697229c47ac581998be8615abc594c308255325fb1
7
+ data.tar.gz: fc69543344585cbfc7cb7608cce5884526481cbf75474a70d2ddea5737ff1eaea722743ad1508e683a2bbeda91e4399d25c8b1294cb3a763d1436b36ece74b77
@@ -1,5 +1,84 @@
1
1
  # Changelog
2
2
 
3
+ ## [v1.2.3](https://github.com/bensheldon/good_job/tree/v1.2.3) (2020-08-27)
4
+
5
+ [Full Changelog](https://github.com/bensheldon/good_job/compare/v1.2.2...v1.2.3)
6
+
7
+ **Closed issues:**
8
+
9
+ - requiring more dependencies in then needed [\#103](https://github.com/bensheldon/good_job/issues/103)
10
+
11
+ **Merged pull requests:**
12
+
13
+ - stop depending on all rails libs [\#104](https://github.com/bensheldon/good_job/pull/104) ([thilo](https://github.com/thilo))
14
+
15
+ ## [v1.2.2](https://github.com/bensheldon/good_job/tree/v1.2.2) (2020-08-27)
16
+
17
+ [Full Changelog](https://github.com/bensheldon/good_job/compare/v1.2.1...v1.2.2)
18
+
19
+ **Implemented enhancements:**
20
+
21
+ - Run Github Action tests against Ruby 2.5, 2.6, 2.7 [\#100](https://github.com/bensheldon/good_job/issues/100)
22
+
23
+ **Fixed bugs:**
24
+
25
+ - Freezes puma on code change [\#95](https://github.com/bensheldon/good_job/issues/95)
26
+ - Ruby 2.7 keyword arguments warning [\#93](https://github.com/bensheldon/good_job/issues/93)
27
+
28
+ **Closed issues:**
29
+
30
+ - Add test for `rails g good\_job:install` [\#57](https://github.com/bensheldon/good_job/issues/57)
31
+
32
+ **Merged pull requests:**
33
+
34
+ - Use more ActiveRecord in Lockable and not connection.execute [\#102](https://github.com/bensheldon/good_job/pull/102) ([bensheldon](https://github.com/bensheldon))
35
+ - Run CI tests on Ruby 2.5, 2.6, and 2.7 [\#101](https://github.com/bensheldon/good_job/pull/101) ([arku](https://github.com/arku))
36
+ - Return to using executor.wrap around Scheduler execution task [\#99](https://github.com/bensheldon/good_job/pull/99) ([bensheldon](https://github.com/bensheldon))
37
+ - Fix Ruby 2.7 keyword arguments warning [\#98](https://github.com/bensheldon/good_job/pull/98) ([arku](https://github.com/arku))
38
+ - Remove executor/reloader for less interlocking [\#97](https://github.com/bensheldon/good_job/pull/97) ([sj26](https://github.com/sj26))
39
+ - Name the thread pools [\#96](https://github.com/bensheldon/good_job/pull/96) ([sj26](https://github.com/sj26))
40
+ - Add test for `rails g good\_job:install` [\#94](https://github.com/bensheldon/good_job/pull/94) ([arku](https://github.com/arku))
41
+
42
+ ## [v1.2.1](https://github.com/bensheldon/good_job/tree/v1.2.1) (2020-08-21)
43
+
44
+ [Full Changelog](https://github.com/bensheldon/good_job/compare/v1.2.0...v1.2.1)
45
+
46
+ **Fixed bugs:**
47
+
48
+ - undefined method `thread\_mattr\_accessor' when not requiring the Sprockets Railstie [\#85](https://github.com/bensheldon/good_job/issues/85)
49
+
50
+ **Closed issues:**
51
+
52
+ - Document comparison of GoodJob with other backends [\#51](https://github.com/bensheldon/good_job/issues/51)
53
+
54
+ **Merged pull requests:**
55
+
56
+ - Explicitly require thread\_mattr\_accessor from ActiveSupport [\#86](https://github.com/bensheldon/good_job/pull/86) ([bensheldon](https://github.com/bensheldon))
57
+ - Add comparison of other backends to Readme [\#84](https://github.com/bensheldon/good_job/pull/84) ([bensheldon](https://github.com/bensheldon))
58
+
59
+ ## [v1.2.0](https://github.com/bensheldon/good_job/tree/v1.2.0) (2020-08-20)
60
+
61
+ [Full Changelog](https://github.com/bensheldon/good_job/compare/v1.1.4...v1.2.0)
62
+
63
+ **Merged pull requests:**
64
+
65
+ - Document GoodJob module [\#83](https://github.com/bensheldon/good_job/pull/83) ([bensheldon](https://github.com/bensheldon))
66
+
67
+ ## [v1.1.4](https://github.com/bensheldon/good_job/tree/v1.1.4) (2020-08-19)
68
+
69
+ [Full Changelog](https://github.com/bensheldon/good_job/compare/v1.1.3...v1.1.4)
70
+
71
+ **Implemented enhancements:**
72
+
73
+ - Explicitly name threads for easier debugging [\#64](https://github.com/bensheldon/good_job/issues/64)
74
+ - Investigate Listen/Notify as alternative to polling [\#54](https://github.com/bensheldon/good_job/issues/54)
75
+
76
+ **Merged pull requests:**
77
+
78
+ - Add Postgres LISTEN/NOTIFY support [\#82](https://github.com/bensheldon/good_job/pull/82) ([bensheldon](https://github.com/bensheldon))
79
+ - Allow Schedulers to filter \#create\_thread to avoid flood of queries when running async with multiple schedulers [\#81](https://github.com/bensheldon/good_job/pull/81) ([bensheldon](https://github.com/bensheldon))
80
+ - Fully name scheduler threadpools and thread names; refactor CLI STDOUT [\#80](https://github.com/bensheldon/good_job/pull/80) ([bensheldon](https://github.com/bensheldon))
81
+
3
82
  ## [v1.1.3](https://github.com/bensheldon/good_job/tree/v1.1.3) (2020-08-14)
4
83
 
5
84
  [Full Changelog](https://github.com/bensheldon/good_job/compare/v1.1.2...v1.1.3)
@@ -73,7 +152,6 @@
73
152
 
74
153
  - Re-perform a job if a StandardError bubbles up; better document job reliability [\#62](https://github.com/bensheldon/good_job/pull/62) ([bensheldon](https://github.com/bensheldon))
75
154
  - Update the setup documentation to use correct bin setup command [\#61](https://github.com/bensheldon/good_job/pull/61) ([jm96441n](https://github.com/jm96441n))
76
- - Allow preservation of finished job records [\#46](https://github.com/bensheldon/good_job/pull/46) ([bensheldon](https://github.com/bensheldon))
77
155
 
78
156
  ## [v1.0.2](https://github.com/bensheldon/good_job/tree/v1.0.2) (2020-07-25)
79
157
 
@@ -104,6 +182,10 @@
104
182
 
105
183
  [Full Changelog](https://github.com/bensheldon/good_job/compare/v0.8.2...v0.9.0)
106
184
 
185
+ **Merged pull requests:**
186
+
187
+ - Allow preservation of finished job records [\#46](https://github.com/bensheldon/good_job/pull/46) ([bensheldon](https://github.com/bensheldon))
188
+
107
189
  ## [v0.8.2](https://github.com/bensheldon/good_job/tree/v0.8.2) (2020-07-18)
108
190
 
109
191
  [Full Changelog](https://github.com/bensheldon/good_job/compare/v0.8.1...v0.8.2)
data/README.md CHANGED
@@ -6,11 +6,24 @@ GoodJob is a multithreaded, Postgres-based, ActiveJob backend for Ruby on Rails.
6
6
 
7
7
  - **Designed for ActiveJob.** Complete support for [async, queues, delays, priorities, timeouts, and retries](https://edgeguides.rubyonrails.org/active_job_basics.html) with near-zero configuration.
8
8
  - **Built for Rails.** Fully adopts Ruby on Rails [threading and code execution guidelines](https://guides.rubyonrails.org/threading_and_code_execution.html) with [Concurrent::Ruby](https://github.com/ruby-concurrency/concurrent-ruby).
9
- - **Backed by Postgres.** Relies upon Postgres integrity and session-level Advisory Locks to provide run-once safety and stay within the limits of `schema.rb`.
9
+ - **Backed by Postgres.** Relies upon Postgres integrity, session-level Advisory Locks to provide run-once safety and stay within the limits of `schema.rb`, and LISTEN/NOTIFY to reduce queuing latency.
10
10
  - **For most workloads.** Targets full-stack teams, economy-minded solo developers, and applications that enqueue less than 1-million jobs/day.
11
11
 
12
12
  For more of the story of GoodJob, read the [introductory blog post](https://island94.org/2020/07/introducing-goodjob-1-0).
13
13
 
14
+ <details>
15
+ <summary><strong>📊 Comparison of GoodJob with other job queue backends (click to expand)</strong></summary>
16
+
17
+ | | Queues, priority, retries | Database | Concurrency | Reliability/Integrity | Latency |
18
+ |-----------------|---------------------------|---------------------------------------|-------------------|------------------------|--------------------------|
19
+ | **GoodJob** | ✅ Yes | ✅ Postgres | ✅ Multithreaded | ✅ ACID, Advisory Locks | ✅ Postgres LISTEN/NOTIFY |
20
+ | **Que** | ✅ Yes | 🟨 Postgres, requires `structure.sql` | ✅ Multithreaded | ✅ ACID, Advisory Locks | ✅ Postgres LISTEN/NOTIFY |
21
+ | **Delayed Job** | ✅ Yes | ✅ Postgres | 🟥 Single-threaded | ✅ ACID, record-based | 🟨 Polling |
22
+ | **Sidekiq** | ✅ Yes | 🟥 Redis | ✅ Multithreaded | 🟥 Crashes lose jobs | ✅ Redis BRPOP |
23
+ | **Sidekiq Pro** | ✅ Yes | 🟥 Redis | ✅ Multithreaded | ✅ Redis RPOPLPUSH | ✅ Redis RPOPLPUSH |
24
+
25
+ </details>
26
+
14
27
  ## Installation
15
28
 
16
29
  Add this line to your application's Gemfile:
@@ -284,15 +297,20 @@ Depending on your application configuration, you may need to take additional ste
284
297
  # config/puma.rb
285
298
 
286
299
  before_fork do
287
- GoodJob::Scheduler.instances.each { |s| s.shutdown }
300
+ GoodJob.shutdown
288
301
  end
289
302
 
290
303
  on_worker_boot do
291
- GoodJob::Scheduler.instances.each { |s| s.restart }
304
+ GoodJob.restart
292
305
  end
293
306
 
294
307
  on_worker_shutdown do
295
- GoodJob::Scheduler.instances.each { |s| s.shutdown }
308
+ GoodJob.shutdown
309
+ end
310
+
311
+ MAIN_PID = Process.pid
312
+ at_exit do
313
+ GoodJob.shutdown if Process.pid == MAIN_PID
296
314
  end
297
315
  ```
298
316
 
@@ -1,3 +1,4 @@
1
1
  #!/usr/bin/env ruby
2
2
  require 'good_job/cli'
3
+ GOOD_JOB_LOG_TO_STDOUT = true
3
4
  GoodJob::CLI.start(ARGV)
@@ -11,14 +11,63 @@ require 'good_job/adapter'
11
11
  require 'good_job/pg_locks'
12
12
  require 'good_job/performer'
13
13
  require 'good_job/current_execution'
14
+ require 'good_job/notifier'
14
15
 
15
16
  require 'active_job/queue_adapters/good_job_adapter'
16
17
 
18
+ # GoodJob is a multithreaded, Postgres-based, ActiveJob backend for Ruby on Rails.
19
+ #
20
+ # +GoodJob+ is the top-level namespace and exposes configuration attributes.
17
21
  module GoodJob
18
- cattr_accessor :logger, default: ActiveSupport::TaggedLogging.new(ActiveSupport::Logger.new(STDOUT))
22
+ # @!attribute [rw] logger
23
+ # @!scope class
24
+ # The logger used by GoodJob
25
+ # @return [Logger]
26
+ mattr_accessor :logger, default: ActiveSupport::TaggedLogging.new(ActiveSupport::Logger.new(STDOUT))
27
+
28
+ # @!attribute [rw] preserve_job_records
29
+ # @!scope class
30
+ # Whether to preserve job records in the database after they have finished for inspection
31
+ # @return [Boolean]
19
32
  mattr_accessor :preserve_job_records, default: false
33
+
34
+ # @!attribute [rw] reperform_jobs_on_standard_error
35
+ # @!scope class
36
+ # Whether to re-perform a job when a type of +StandardError+ is raised and bubbles up to the GoodJob backend
37
+ # @return [Boolean]
20
38
  mattr_accessor :reperform_jobs_on_standard_error, default: true
39
+
40
+ # @!attribute [rw] on_thread_error
41
+ # @!scope class
42
+ # Called when a thread raises an error
43
+ # @example Send errors to Sentry
44
+ # # config/initializers/good_job.rb
45
+ #
46
+ # # With Sentry (or Bugsnag, Airbrake, Honeybadger, etc.)
47
+ # GoodJob.on_thread_error = -> (exception) { Raven.capture_exception(exception) }
48
+ # @return [#call, nil]
21
49
  mattr_accessor :on_thread_error, default: nil
22
50
 
51
+ # Shuts down all execution pools
52
+ # @param wait [Boolean] whether to wait for shutdown
53
+ # @return [void]
54
+ def self.shutdown(wait: true)
55
+ Notifier.instances.each { |adapter| adapter.shutdown(wait: wait) }
56
+ Scheduler.instances.each { |scheduler| scheduler.shutdown(wait: wait) }
57
+ end
58
+
59
+ # Tests if execution pools are shut down
60
+ # @return [Boolean] whether execution pools are shut down
61
+ def self.shutdown?
62
+ Notifier.instances.all?(&:shutdown?) && Scheduler.instances.all?(&:shutdown?)
63
+ end
64
+
65
+ # Restarts all execution pools
66
+ # @return [void]
67
+ def self.restart
68
+ Notifier.instances.each(&:restart)
69
+ Scheduler.instances.each(&:restart)
70
+ end
71
+
23
72
  ActiveSupport.run_load_hooks(:good_job, self)
24
73
  end
@@ -2,25 +2,29 @@ module GoodJob
2
2
  class Adapter
3
3
  EXECUTION_MODES = [:async, :external, :inline].freeze
4
4
 
5
- def initialize(execution_mode: nil, max_threads: nil, poll_interval: nil, scheduler: nil, inline: false)
5
+ def initialize(execution_mode: nil, queues: nil, max_threads: nil, poll_interval: nil, scheduler: nil, notifier: nil, inline: false)
6
6
  if inline && execution_mode.nil?
7
7
  ActiveSupport::Deprecation.warn('GoodJob::Adapter#new(inline: true) is deprecated; use GoodJob::Adapter.new(execution_mode: :inline) instead')
8
8
  execution_mode = :inline
9
9
  end
10
10
 
11
- configuration = GoodJob::Configuration.new({
12
- execution_mode: execution_mode,
13
- max_threads: max_threads,
14
- poll_interval: poll_interval,
15
- },
16
- env: ENV)
17
-
18
- raise ArgumentError, "execution_mode: must be one of #{EXECUTION_MODES.join(', ')}." unless EXECUTION_MODES.include?(configuration.execution_mode)
11
+ configuration = GoodJob::Configuration.new(
12
+ {
13
+ execution_mode: execution_mode,
14
+ queues: queues,
15
+ max_threads: max_threads,
16
+ poll_interval: poll_interval,
17
+ }
18
+ )
19
19
 
20
20
  @execution_mode = configuration.execution_mode
21
+ raise ArgumentError, "execution_mode: must be one of #{EXECUTION_MODES.join(', ')}." unless EXECUTION_MODES.include?(@execution_mode)
21
22
 
22
- @scheduler = scheduler
23
- @scheduler = GoodJob::Scheduler.from_configuration(configuration) if @execution_mode == :async && @scheduler.blank?
23
+ if @execution_mode == :async # rubocop:disable Style/GuardClause
24
+ @notifier = notifier || GoodJob::Notifier.new
25
+ @scheduler = scheduler || GoodJob::Scheduler.from_configuration(configuration)
26
+ @notifier.recipients << [@scheduler, :create_thread]
27
+ end
24
28
  end
25
29
 
26
30
  def enqueue(active_job)
@@ -42,12 +46,14 @@ module GoodJob
42
46
  end
43
47
  end
44
48
 
45
- @scheduler.create_thread if execute_async?
49
+ executed_locally = execute_async? && @scheduler.create_thread(queue_name: good_job.queue_name)
50
+ Notifier.notify(queue_name: good_job.queue_name) unless executed_locally
46
51
 
47
52
  good_job
48
53
  end
49
54
 
50
55
  def shutdown(wait: true)
56
+ @notifier&.shutdown(wait: wait)
51
57
  @scheduler&.shutdown(wait: wait)
52
58
  end
53
59
 
@@ -18,8 +18,10 @@ module GoodJob
18
18
  def start
19
19
  set_up_application!
20
20
 
21
- configuration = Configuration.new(options, env: ENV)
22
- scheduler = Scheduler.from_configuration(configuration)
21
+ notifier = GoodJob::Notifier.new
22
+ configuration = GoodJob::Configuration.new(options)
23
+ scheduler = GoodJob::Scheduler.from_configuration(configuration)
24
+ notifier.recipients << [scheduler, :create_thread]
23
25
 
24
26
  @stop_good_job_executable = false
25
27
  %w[INT TERM].each do |signal|
@@ -28,9 +30,10 @@ module GoodJob
28
30
 
29
31
  Kernel.loop do
30
32
  sleep 0.1
31
- break if @stop_good_job_executable || scheduler.shutdown?
33
+ break if @stop_good_job_executable || scheduler.shutdown? || notifier.shutdown?
32
34
  end
33
35
 
36
+ notifier.shutdown
34
37
  scheduler.shutdown
35
38
  end
36
39
 
@@ -41,6 +44,7 @@ module GoodJob
41
44
  type: :numeric,
42
45
  default: 24 * 60 * 60,
43
46
  desc: "Delete records finished more than this many seconds ago"
47
+
44
48
  def cleanup_preserved_jobs
45
49
  set_up_application!
46
50
 
@@ -55,6 +59,10 @@ module GoodJob
55
59
  no_commands do
56
60
  def set_up_application!
57
61
  require RAILS_ENVIRONMENT_RB
62
+ return unless defined?(GOOD_JOB_LOG_TO_STDOUT) && GOOD_JOB_LOG_TO_STDOUT && !ActiveSupport::Logger.logger_outputs_to?(GoodJob.logger, STDOUT)
63
+
64
+ GoodJob::LogSubscriber.loggers << ActiveSupport::TaggedLogging.new(ActiveSupport::Logger.new(STDOUT))
65
+ GoodJob::LogSubscriber.reset_logger
58
66
  end
59
67
  end
60
68
  end
@@ -1,3 +1,5 @@
1
+ require 'active_support/core_ext/module/attribute_accessors_per_thread'
2
+
1
3
  module GoodJob
2
4
  # Thread-local attributes for passing values from Instrumentation.
3
5
  # (Cannot use ActiveSupport::CurrentAttributes because ActiveJob resets it)
@@ -21,5 +23,15 @@ module GoodJob
21
23
  self.error_on_retry = nil
22
24
  self.error_on_discard = nil
23
25
  end
26
+
27
+ # @return [Integer] Current process ID
28
+ def self.process_id
29
+ Process.pid
30
+ end
31
+
32
+ # @return [String] Current thread name
33
+ def self.thread_name
34
+ (Thread.current.name || Thread.current.object_id).to_s
35
+ end
24
36
  end
25
37
  end
@@ -9,6 +9,25 @@ module GoodJob
9
9
 
10
10
  self.table_name = 'good_jobs'.freeze
11
11
 
12
+ def self.queue_parser(string)
13
+ string = string.presence || '*'
14
+
15
+ if string.first == '-'
16
+ exclude_queues = true
17
+ string = string[1..-1]
18
+ end
19
+
20
+ queues = string.split(',').map(&:strip)
21
+
22
+ if queues.include?('*')
23
+ { all: true }
24
+ elsif exclude_queues
25
+ { exclude: queues }
26
+ else
27
+ { include: queues }
28
+ end
29
+ end
30
+
12
31
  scope :unfinished, (lambda do
13
32
  if column_names.include?('finished_at')
14
33
  where(finished_at: nil)
@@ -21,20 +40,14 @@ module GoodJob
21
40
  scope :priority_ordered, -> { order('priority DESC NULLS LAST') }
22
41
  scope :finished, ->(timestamp = nil) { timestamp ? where(arel_table['finished_at'].lteq(timestamp)) : where.not(finished_at: nil) }
23
42
  scope :queue_string, (lambda do |string|
24
- string = string.presence || '*'
25
-
26
- if string.first == '-'
27
- exclude_queues = true
28
- string = string[1..-1]
29
- end
30
-
31
- queue_names_without_all = string.split(',').map(&:strip).reject { |q| q == '*' }
32
- return if queue_names_without_all.size.zero?
33
-
34
- if exclude_queues
35
- where.not(queue_name: queue_names_without_all).or where(queue_name: nil)
36
- else
37
- where(queue_name: queue_names_without_all)
43
+ parsed = queue_parser(string)
44
+
45
+ if parsed[:all]
46
+ all
47
+ elsif parsed[:exclude]
48
+ where.not(queue_name: parsed[:exclude]).or where(queue_name: nil)
49
+ elsif parsed[:include]
50
+ where(queue_name: parsed[:include])
38
51
  end
39
52
  end)
40
53
 
@@ -45,7 +58,8 @@ module GoodJob
45
58
 
46
59
  unfinished.priority_ordered.only_scheduled.limit(1).with_advisory_lock do |good_jobs|
47
60
  good_job = good_jobs.first
48
- break unless good_job
61
+ # TODO: Determine why some records are fetched without an advisory lock at all
62
+ break unless good_job&.owns_advisory_lock?
49
63
 
50
64
  result, error = good_job.perform
51
65
  end
@@ -60,7 +74,7 @@ module GoodJob
60
74
  queue_name: active_job.queue_name.presence || DEFAULT_QUEUE_NAME,
61
75
  priority: active_job.priority || DEFAULT_PRIORITY,
62
76
  serialized_params: active_job.serialize,
63
- scheduled_at: scheduled_at || Time.current,
77
+ scheduled_at: scheduled_at,
64
78
  create_with_advisory_lock: create_with_advisory_lock
65
79
  )
66
80
 
@@ -89,7 +103,7 @@ module GoodJob
89
103
  )
90
104
 
91
105
  begin
92
- ActiveSupport::Notifications.instrument("perform_job.good_job", { good_job: self }) do
106
+ ActiveSupport::Notifications.instrument("perform_job.good_job", { good_job: self, process_id: GoodJob::CurrentExecution.process_id, thread_name: GoodJob::CurrentExecution.thread_name }) do
93
107
  result = ActiveJob::Base.execute(params)
94
108
  end
95
109
  rescue StandardError => e
@@ -55,19 +55,17 @@ module GoodJob
55
55
  end
56
56
 
57
57
  def advisory_lock
58
- query = <<~SQL
59
- SELECT 1 AS one
60
- WHERE pg_try_advisory_lock(('x'||substr(md5(:table_name || :id::text), 1, 16))::bit(64)::bigint)
58
+ where_sql = <<~SQL
59
+ pg_try_advisory_lock(('x'||substr(md5(:table_name || :id::text), 1, 16))::bit(64)::bigint)
61
60
  SQL
62
- self.class.connection.execute(sanitize_sql_for_conditions([query, { table_name: self.class.table_name, id: send(self.class.primary_key) }])).ntuples.positive?
61
+ self.class.unscoped.where(where_sql, { table_name: self.class.table_name, id: send(self.class.primary_key) }).exists?
63
62
  end
64
63
 
65
64
  def advisory_unlock
66
- query = <<~SQL
67
- SELECT 1 AS one
68
- WHERE pg_advisory_unlock(('x'||substr(md5(:table_name || :id::text), 1, 16))::bit(64)::bigint)
65
+ where_sql = <<~SQL
66
+ pg_advisory_unlock(('x'||substr(md5(:table_name || :id::text), 1, 16))::bit(64)::bigint)
69
67
  SQL
70
- self.class.connection.execute(sanitize_sql_for_conditions([query, { table_name: self.class.table_name, id: send(self.class.primary_key) }])).ntuples.positive?
68
+ self.class.unscoped.where(where_sql, { table_name: self.class.table_name, id: send(self.class.primary_key) }).exists?
71
69
  end
72
70
 
73
71
  def advisory_lock!
@@ -85,11 +83,11 @@ module GoodJob
85
83
  end
86
84
 
87
85
  def advisory_locked?
88
- self.class.advisory_locked.where(id: send(self.class.primary_key)).any?
86
+ self.class.unscoped.advisory_locked.where(id: send(self.class.primary_key)).exists?
89
87
  end
90
88
 
91
89
  def owns_advisory_lock?
92
- self.class.owns_advisory_locked.where(id: send(self.class.primary_key)).any?
90
+ self.class.unscoped.owns_advisory_locked.where(id: send(self.class.primary_key)).exists?
93
91
  end
94
92
 
95
93
  def advisory_unlock!
@@ -32,7 +32,7 @@ module GoodJob
32
32
  performer_name = event.payload[:performer_name]
33
33
  process_id = event.payload[:process_id]
34
34
 
35
- info_and_stdout(tags: [process_id]) do
35
+ info(tags: [process_id]) do
36
36
  "GoodJob started scheduler with queues=#{performer_name} max_threads=#{max_threads} poll_interval=#{poll_interval}."
37
37
  end
38
38
  end
@@ -40,7 +40,7 @@ module GoodJob
40
40
  def scheduler_shutdown_start(event)
41
41
  process_id = event.payload[:process_id]
42
42
 
43
- info_and_stdout(tags: [process_id]) do
43
+ info(tags: [process_id]) do
44
44
  "GoodJob shutting down scheduler..."
45
45
  end
46
46
  end
@@ -48,7 +48,7 @@ module GoodJob
48
48
  def scheduler_shutdown(event)
49
49
  process_id = event.payload[:process_id]
50
50
 
51
- info_and_stdout(tags: [process_id]) do
51
+ info(tags: [process_id]) do
52
52
  "GoodJob scheduler is shut down."
53
53
  end
54
54
  end
@@ -56,24 +56,100 @@ module GoodJob
56
56
  def scheduler_restart_pools(event)
57
57
  process_id = event.payload[:process_id]
58
58
 
59
- info_and_stdout(tags: [process_id]) do
59
+ info(tags: [process_id]) do
60
60
  "GoodJob scheduler has restarted."
61
61
  end
62
62
  end
63
63
 
64
+ def perform_job(event)
65
+ good_job = event.payload[:good_job]
66
+ process_id = event.payload[:process_id]
67
+ thread_name = event.payload[:thread_name]
68
+
69
+ info(tags: [process_id, thread_name]) do
70
+ "Executed GoodJob #{good_job.id}"
71
+ end
72
+ end
73
+
74
+ def notifier_listen(_event)
75
+ info do
76
+ "Notifier subscribed with LISTEN"
77
+ end
78
+ end
79
+
80
+ def notifier_notified(event)
81
+ payload = event.payload[:payload]
82
+
83
+ debug do
84
+ "Notifier received payload: #{payload}"
85
+ end
86
+ end
87
+
88
+ def notifier_notify_error(event)
89
+ error = event.payload[:error]
90
+
91
+ error do
92
+ "Notifier errored: #{error}"
93
+ end
94
+ end
95
+
96
+ def notifier_unlisten(_event)
97
+ info do
98
+ "Notifier unsubscribed with UNLISTEN"
99
+ end
100
+ end
101
+
64
102
  def cleanup_preserved_jobs(event)
65
103
  timestamp = event.payload[:timestamp]
66
104
  deleted_records_count = event.payload[:deleted_records_count]
67
105
 
68
- info_and_stdout do
106
+ info do
69
107
  "GoodJob deleted #{deleted_records_count} preserved #{'job'.pluralize(deleted_records_count)} finished before #{timestamp}."
70
108
  end
71
109
  end
72
110
 
73
- private
111
+ class << self
112
+ def loggers
113
+ @_loggers ||= [GoodJob.logger]
114
+ end
115
+
116
+ def logger
117
+ @_logger ||= begin
118
+ logger = Logger.new(StringIO.new)
119
+ loggers.each do |each_logger|
120
+ logger.extend(ActiveSupport::Logger.broadcast(each_logger))
121
+ end
122
+ logger
123
+ end
124
+ end
125
+
126
+ def reset_logger
127
+ @_logger = nil
128
+ end
129
+ end
74
130
 
75
131
  def logger
76
- GoodJob.logger
132
+ GoodJob::LogSubscriber.logger
133
+ end
134
+
135
+ private
136
+
137
+ def tag_logger(*tags, &block)
138
+ tags = tags.dup.unshift("GoodJob").compact
139
+
140
+ self.class.loggers.inject(block) do |inner, each_logger|
141
+ if each_logger.respond_to?(:tagged)
142
+ tags_for_logger = if each_logger.formatter.current_tags.include?("ActiveJob")
143
+ ["ActiveJob"] + tags
144
+ else
145
+ tags
146
+ end
147
+
148
+ proc { each_logger.tagged(*tags_for_logger, &inner) }
149
+ else
150
+ inner
151
+ end
152
+ end.call
77
153
  end
78
154
 
79
155
  %w(info debug warn error fatal unknown).each do |level|
@@ -81,30 +157,11 @@ module GoodJob
81
157
  def #{level}(progname = nil, tags: [], &block)
82
158
  return unless logger
83
159
 
84
- if logger.respond_to?(:tagged)
85
- tags.unshift "GoodJob" unless logger.formatter.current_tags.include?("GoodJob")
86
- logger.tagged(*tags.compact) do
87
- logger.#{level}(progname, &block)
88
- end
89
- else
160
+ tag_logger(*tags) do
90
161
  logger.#{level}(progname, &block)
91
162
  end
92
163
  end
93
164
  METHOD
94
165
  end
95
-
96
- def info_and_stdout(progname = nil, tags: [], &block)
97
- unless ActiveSupport::Logger.logger_outputs_to?(logger, STDOUT)
98
- tags_string = (['GoodJob'] + tags).map { |t| "[#{t}]" }.join(' ')
99
- stdout_message = "#{tags_string} #{yield}"
100
- $stdout.puts stdout_message
101
- end
102
-
103
- info(progname, tags: [], &block)
104
- end
105
-
106
- def thread_name
107
- Thread.current.name || Thread.current.object_id
108
- end
109
166
  end
110
167
  end
@@ -18,8 +18,17 @@ module GoodJob
18
18
  schedulers.each { |s| s.restart(wait: wait) }
19
19
  end
20
20
 
21
- def create_thread
22
- schedulers.all?(&:create_thread)
21
+ def create_thread(state = nil)
22
+ results = []
23
+ any_true = schedulers.any? do |scheduler|
24
+ scheduler.create_thread(state).tap { |result| results << result }
25
+ end
26
+
27
+ if any_true
28
+ true
29
+ else
30
+ results.any? { |result| result == false } ? false : nil
31
+ end
23
32
  end
24
33
  end
25
34
  end
@@ -0,0 +1,127 @@
1
+ require 'concurrent/atomic/atomic_boolean'
2
+
3
+ module GoodJob # :nodoc:
4
+ #
5
+ # Wrapper for Postgres LISTEN/NOTIFY
6
+ #
7
+ class Notifier
8
+ CHANNEL = 'good_job'.freeze
9
+ POOL_OPTIONS = {
10
+ name: name,
11
+ min_threads: 0,
12
+ max_threads: 1,
13
+ auto_terminate: true,
14
+ idletime: 60,
15
+ max_queue: 1,
16
+ fallback_policy: :discard,
17
+ }.freeze
18
+ WAIT_INTERVAL = 1
19
+
20
+ # @!attribute [r] instances
21
+ # @!scope class
22
+ # @return [array<GoodJob:Adapter>] the instances of +GoodJob::Notifier+
23
+ cattr_reader :instances, default: [], instance_reader: false
24
+
25
+ def self.notify(message)
26
+ connection = ActiveRecord::Base.connection
27
+ connection.exec_query <<~SQL
28
+ NOTIFY #{CHANNEL}, #{connection.quote(message.to_json)}
29
+ SQL
30
+ end
31
+
32
+ attr_reader :recipients
33
+
34
+ def initialize(*recipients)
35
+ @recipients = Concurrent::Array.new(recipients)
36
+ @listening = Concurrent::AtomicBoolean.new(false)
37
+
38
+ self.class.instances << self
39
+
40
+ create_pool
41
+ listen
42
+ end
43
+
44
+ def listening?
45
+ @listening.true?
46
+ end
47
+
48
+ def restart(wait: true)
49
+ shutdown(wait: wait)
50
+ create_pool
51
+ listen
52
+ end
53
+
54
+ def shutdown(wait: true)
55
+ return unless @pool.running?
56
+
57
+ @pool.shutdown
58
+ @pool.wait_for_termination if wait
59
+ end
60
+
61
+ def shutdown?
62
+ !@pool.running?
63
+ end
64
+
65
+ private
66
+
67
+ def create_pool
68
+ @pool = Concurrent::ThreadPoolExecutor.new(POOL_OPTIONS)
69
+ end
70
+
71
+ def listen
72
+ future = Concurrent::Future.new(args: [@recipients, @pool, @listening], executor: @pool) do |recipients, pool, listening|
73
+ begin
74
+ with_listen_connection do |conn|
75
+ ActiveSupport::Notifications.instrument("notifier_listen.good_job") do
76
+ conn.async_exec "LISTEN #{CHANNEL}"
77
+ end
78
+
79
+ ActiveSupport::Dependencies.interlock.permit_concurrent_loads do
80
+ while pool.running?
81
+ listening.make_true
82
+ conn.wait_for_notify(WAIT_INTERVAL) do |channel, _pid, payload|
83
+ listening.make_false
84
+ next unless channel == CHANNEL
85
+
86
+ ActiveSupport::Notifications.instrument("notifier_notified.good_job", { payload: payload })
87
+ parsed_payload = JSON.parse(payload, symbolize_names: true)
88
+ recipients.each do |recipient|
89
+ target, method_name = recipient.is_a?(Array) ? recipient : [recipient, :call]
90
+ target.send(method_name, parsed_payload)
91
+ end
92
+ end
93
+ listening.make_false
94
+ end
95
+ end
96
+ end
97
+ rescue StandardError => e
98
+ ActiveSupport::Notifications.instrument("notifier_notify_error.good_job", { error: e })
99
+ raise
100
+ ensure
101
+ @listening.make_false
102
+ ActiveSupport::Notifications.instrument("notifier_unlisten.good_job") do
103
+ conn.async_exec "UNLISTEN *"
104
+ end
105
+ end
106
+ end
107
+
108
+ future.add_observer(self, :listen_observer)
109
+ future.execute
110
+ end
111
+
112
+ def listen_observer(_time, _result, _thread_error)
113
+ listen unless shutdown?
114
+ end
115
+
116
+ def with_listen_connection
117
+ ar_conn = ActiveRecord::Base.connection_pool.checkout.tap do |conn|
118
+ ActiveRecord::Base.connection_pool.remove(conn)
119
+ end
120
+ pg_conn = ar_conn.raw_connection
121
+ pg_conn.exec("SET application_name = #{pg_conn.escape_identifier(self.class.name)}")
122
+ yield pg_conn
123
+ ensure
124
+ ar_conn.disconnect!
125
+ end
126
+ end
127
+ end
@@ -2,14 +2,21 @@ module GoodJob
2
2
  class Performer
3
3
  attr_reader :name
4
4
 
5
- def initialize(target, method_name, name: nil)
5
+ def initialize(target, method_name, name: nil, filter: nil)
6
6
  @target = target
7
7
  @method_name = method_name
8
8
  @name = name
9
+ @filter = filter
9
10
  end
10
11
 
11
12
  def next
12
13
  @target.public_send(@method_name)
13
14
  end
15
+
16
+ def next?(state = {})
17
+ return true unless @filter.respond_to?(:call)
18
+
19
+ @filter.call(state)
20
+ end
14
21
  end
15
22
  end
@@ -3,12 +3,14 @@ require "concurrent/timer_task"
3
3
  require "concurrent/utility/processor_counter"
4
4
 
5
5
  module GoodJob # :nodoc:
6
+ #
6
7
  # Schedulers are generic thread execution pools that are responsible for
7
8
  # periodically checking for available execution tasks, executing tasks in a
8
9
  # bounded thread-pool, and efficiently scaling execution threads.
9
10
  #
10
11
  # Schedulers are "generic" in the sense that they delegate task execution
11
12
  # details to a "Performer" object that responds to #next.
13
+ #
12
14
  class Scheduler
13
15
  # Defaults for instance of Concurrent::TimerTask
14
16
  DEFAULT_TIMER_OPTIONS = {
@@ -19,7 +21,7 @@ module GoodJob # :nodoc:
19
21
 
20
22
  # Defaults for instance of Concurrent::ThreadPoolExecutor
21
23
  DEFAULT_POOL_OPTIONS = {
22
- name: 'good_job',
24
+ name: name,
23
25
  min_threads: 0,
24
26
  max_threads: Concurrent.processor_count,
25
27
  auto_terminate: true,
@@ -43,10 +45,20 @@ module GoodJob # :nodoc:
43
45
  max_threads = (max_threads || configuration.max_threads).to_i
44
46
 
45
47
  job_query = GoodJob::Job.queue_string(queue_string)
46
- job_performer = GoodJob::Performer.new(job_query, :perform_with_advisory_lock, name: queue_string)
48
+ parsed = GoodJob::Job.queue_parser(queue_string)
49
+ job_filter = proc do |state|
50
+ if parsed[:exclude]
51
+ !parsed[:exclude].include? state[:queue_name]
52
+ elsif parsed[:include]
53
+ parsed[:include].include? state[:queue_name]
54
+ else
55
+ true
56
+ end
57
+ end
58
+ job_performer = GoodJob::Performer.new(job_query, :perform_with_advisory_lock, name: queue_string, filter: job_filter)
47
59
 
48
60
  timer_options = {}
49
- timer_options[:execution_interval] = configuration.poll_interval if configuration.poll_interval.positive?
61
+ timer_options[:execution_interval] = configuration.poll_interval
50
62
 
51
63
  pool_options = {
52
64
  max_threads: max_threads,
@@ -74,6 +86,8 @@ module GoodJob # :nodoc:
74
86
  @pool_options = DEFAULT_POOL_OPTIONS.merge(pool_options)
75
87
  @timer_options = DEFAULT_TIMER_OPTIONS.merge(timer_options)
76
88
 
89
+ @pool_options[:name] = "GoodJob::Scheduler(queues=#{@performer.name} max_threads=#{@pool_options[:max_threads]} poll_interval=#{@timer_options[:execution_interval]})"
90
+
77
91
  create_pools
78
92
  end
79
93
 
@@ -83,8 +97,8 @@ module GoodJob # :nodoc:
83
97
  def shutdown(wait: true)
84
98
  @_shutdown = true
85
99
 
86
- ActiveSupport::Notifications.instrument("scheduler_shutdown_start.good_job", { wait: wait, process_id: process_id })
87
- ActiveSupport::Notifications.instrument("scheduler_shutdown.good_job", { wait: wait, process_id: process_id }) do
100
+ instrument("scheduler_shutdown_start", { wait: wait })
101
+ instrument("scheduler_shutdown", { wait: wait }) do
88
102
  if @timer&.running?
89
103
  @timer.shutdown
90
104
  @timer.wait_for_termination if wait
@@ -107,16 +121,22 @@ module GoodJob # :nodoc:
107
121
  # @param wait [Boolean] Wait for actively executing jobs to finish
108
122
  # @return [void]
109
123
  def restart(wait: true)
110
- ActiveSupport::Notifications.instrument("scheduler_restart_pools.good_job", { process_id: process_id }) do
124
+ instrument("scheduler_restart_pools") do
111
125
  shutdown(wait: wait) unless shutdown?
112
126
  create_pools
127
+ @_shutdown = false
113
128
  end
114
129
  end
115
130
 
116
- # Triggers the execution the Performer, if an execution thread is available.
117
- # @return [Boolean]
118
- def create_thread
119
- return false unless @pool.ready_worker_count.positive?
131
+ # Triggers a Performer execution, if an execution thread is available.
132
+ # @param state [nil, Object] Allows Performer#next? to accept or reject the execution
133
+ # @return [nil, Boolean] if the thread was created
134
+ def create_thread(state = nil)
135
+ return nil unless @pool.running? && @pool.ready_worker_count.positive?
136
+
137
+ if state
138
+ return false unless @performer.next?(state)
139
+ end
120
140
 
121
141
  future = Concurrent::Future.new(args: [@performer], executor: @pool) do |performer|
122
142
  output = nil
@@ -125,6 +145,7 @@ module GoodJob # :nodoc:
125
145
  end
126
146
  future.add_observer(self, :task_observer)
127
147
  future.execute
148
+
128
149
  true
129
150
  end
130
151
 
@@ -133,7 +154,7 @@ module GoodJob # :nodoc:
133
154
  # @return [void]
134
155
  def timer_observer(time, executed_task, thread_error)
135
156
  GoodJob.on_thread_error.call(thread_error) if thread_error && GoodJob.on_thread_error.respond_to?(:call)
136
- ActiveSupport::Notifications.instrument("finished_timer_task.good_job", { result: executed_task, error: thread_error, time: time })
157
+ instrument("finished_timer_task", { result: executed_task, error: thread_error, time: time })
137
158
  end
138
159
 
139
160
  # Invoked on completion of ThreadPoolExecutor task
@@ -141,7 +162,7 @@ module GoodJob # :nodoc:
141
162
  # @return [void]
142
163
  def task_observer(time, output, thread_error)
143
164
  GoodJob.on_thread_error.call(thread_error) if thread_error && GoodJob.on_thread_error.respond_to?(:call)
144
- ActiveSupport::Notifications.instrument("finished_job_task.good_job", { result: output, error: thread_error, time: time })
165
+ instrument("finished_job_task", { result: output, error: thread_error, time: time })
145
166
  create_thread if output
146
167
  end
147
168
 
@@ -149,7 +170,7 @@ module GoodJob # :nodoc:
149
170
 
150
171
  # @return [void]
151
172
  def create_pools
152
- ActiveSupport::Notifications.instrument("scheduler_create_pools.good_job", { performer_name: @performer.name, max_threads: @pool_options[:max_threads], poll_interval: @timer_options[:execution_interval], process_id: process_id }) do
173
+ instrument("scheduler_create_pools", { performer_name: @performer.name, max_threads: @pool_options[:max_threads], poll_interval: @timer_options[:execution_interval] }) do
153
174
  @pool = ThreadPoolExecutor.new(@pool_options)
154
175
  next unless @timer_options[:execution_interval].positive?
155
176
 
@@ -159,14 +180,14 @@ module GoodJob # :nodoc:
159
180
  end
160
181
  end
161
182
 
162
- # @return [Integer] Current process ID
163
- def process_id
164
- Process.pid
165
- end
183
+ def instrument(name, payload = {}, &block)
184
+ payload = payload.reverse_merge({
185
+ scheduler: self,
186
+ process_id: GoodJob::CurrentExecution.process_id,
187
+ thread_name: GoodJob::CurrentExecution.thread_name,
188
+ })
166
189
 
167
- # @return [String] Current thread name
168
- def thread_name
169
- (Thread.current.name || Thread.current.object_id).to_s
190
+ ActiveSupport::Notifications.instrument("#{name}.good_job", payload, &block)
170
191
  end
171
192
  end
172
193
 
@@ -1,3 +1,3 @@
1
1
  module GoodJob
2
- VERSION = '1.1.3'.freeze
2
+ VERSION = '1.2.3'.freeze
3
3
  end
metadata CHANGED
@@ -1,15 +1,43 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: good_job
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.3
4
+ version: 1.2.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ben Sheldon
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2020-08-14 00:00:00.000000000 Z
11
+ date: 2020-08-27 00:00:00.000000000 Z
12
12
  dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activejob
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: 5.1.0
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: 5.1.0
27
+ - !ruby/object:Gem::Dependency
28
+ name: activerecord
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: 5.1.0
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: 5.1.0
13
41
  - !ruby/object:Gem::Dependency
14
42
  name: concurrent-ruby
15
43
  requirement: !ruby/object:Gem::Requirement
@@ -39,7 +67,7 @@ dependencies:
39
67
  - !ruby/object:Gem::Version
40
68
  version: 1.0.0
41
69
  - !ruby/object:Gem::Dependency
42
- name: rails
70
+ name: railties
43
71
  requirement: !ruby/object:Gem::Requirement
44
72
  requirements:
45
73
  - - ">="
@@ -151,7 +179,7 @@ dependencies:
151
179
  - !ruby/object:Gem::Version
152
180
  version: '0'
153
181
  - !ruby/object:Gem::Dependency
154
- name: pry
182
+ name: pry-rails
155
183
  requirement: !ruby/object:Gem::Requirement
156
184
  requirements:
157
185
  - - ">="
@@ -178,6 +206,20 @@ dependencies:
178
206
  - - ">="
179
207
  - !ruby/object:Gem::Version
180
208
  version: '0'
209
+ - !ruby/object:Gem::Dependency
210
+ name: rbtrace
211
+ requirement: !ruby/object:Gem::Requirement
212
+ requirements:
213
+ - - ">="
214
+ - !ruby/object:Gem::Version
215
+ version: '0'
216
+ type: :development
217
+ prerelease: false
218
+ version_requirements: !ruby/object:Gem::Requirement
219
+ requirements:
220
+ - - ">="
221
+ - !ruby/object:Gem::Version
222
+ version: '0'
181
223
  - !ruby/object:Gem::Dependency
182
224
  name: rspec-rails
183
225
  requirement: !ruby/object:Gem::Requirement
@@ -248,6 +290,20 @@ dependencies:
248
290
  - - ">="
249
291
  - !ruby/object:Gem::Version
250
292
  version: '0'
293
+ - !ruby/object:Gem::Dependency
294
+ name: sigdump
295
+ requirement: !ruby/object:Gem::Requirement
296
+ requirements:
297
+ - - ">="
298
+ - !ruby/object:Gem::Version
299
+ version: '0'
300
+ type: :development
301
+ prerelease: false
302
+ version_requirements: !ruby/object:Gem::Requirement
303
+ requirements:
304
+ - - ">="
305
+ - !ruby/object:Gem::Version
306
+ version: '0'
251
307
  - !ruby/object:Gem::Dependency
252
308
  name: yard
253
309
  requirement: !ruby/object:Gem::Requirement
@@ -289,6 +345,7 @@ files:
289
345
  - lib/good_job/lockable.rb
290
346
  - lib/good_job/log_subscriber.rb
291
347
  - lib/good_job/multi_scheduler.rb
348
+ - lib/good_job/notifier.rb
292
349
  - lib/good_job/performer.rb
293
350
  - lib/good_job/pg_locks.rb
294
351
  - lib/good_job/railtie.rb