good_job 1.0.3 → 1.1.4

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.
@@ -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)
@@ -1,18 +1,9 @@
1
1
  module ActiveJob
2
2
  module QueueAdapters
3
3
  class GoodJobAdapter < GoodJob::Adapter
4
- def initialize(execution_mode: nil)
5
- execution_mode = if execution_mode
6
- execution_mode
7
- elsif ENV['GOOD_JOB_EXECUTION_MODE'].present?
8
- ENV['GOOD_JOB_EXECUTION_MODE'].to_sym
9
- elsif Rails.env.development? || Rails.env.test?
10
- :inline
11
- else
12
- :external
13
- end
14
-
15
- super(execution_mode: execution_mode)
4
+ def initialize(execution_mode: nil, max_threads: nil, poll_interval: nil, scheduler: nil, inline: false)
5
+ configuration = GoodJob::Configuration.new({ execution_mode: execution_mode }, env: ENV)
6
+ super(execution_mode: configuration.rails_execution_mode, max_threads: max_threads, poll_interval: poll_interval, scheduler: scheduler, inline: inline)
16
7
  end
17
8
  end
18
9
  end
@@ -1,20 +1,46 @@
1
1
  require "rails"
2
2
  require 'good_job/railtie'
3
3
 
4
- require 'good_job/logging'
4
+ require 'good_job/configuration'
5
+ require 'good_job/log_subscriber'
5
6
  require 'good_job/lockable'
6
7
  require 'good_job/job'
7
8
  require 'good_job/scheduler'
9
+ require 'good_job/multi_scheduler'
8
10
  require 'good_job/adapter'
9
11
  require 'good_job/pg_locks'
10
12
  require 'good_job/performer'
13
+ require 'good_job/current_execution'
14
+ require 'good_job/notifier'
11
15
 
12
16
  require 'active_job/queue_adapters/good_job_adapter'
13
17
 
14
18
  module GoodJob
19
+ mattr_accessor :logger, default: ActiveSupport::TaggedLogging.new(ActiveSupport::Logger.new(STDOUT))
15
20
  mattr_accessor :preserve_job_records, default: false
16
21
  mattr_accessor :reperform_jobs_on_standard_error, default: true
17
- include Logging
22
+ mattr_accessor :on_thread_error, default: nil
18
23
 
19
24
  ActiveSupport.run_load_hooks(:good_job, self)
25
+
26
+ # Shuts down all execution pools
27
+ # @param wait [Boolean] whether to wait for shutdown
28
+ # @return [void]
29
+ def self.shutdown(wait: true)
30
+ Notifier.instances.each { |adapter| adapter.shutdown(wait: wait) }
31
+ Scheduler.instances.each { |scheduler| scheduler.shutdown(wait: wait) }
32
+ end
33
+
34
+ # Tests if execution pools are shut down
35
+ # @return [Boolean] whether execution pools are shut down
36
+ def self.shutdown?
37
+ Notifier.instances.all?(&:shutdown?) && Scheduler.instances.all?(&:shutdown?)
38
+ end
39
+
40
+ # Restarts all execution pools
41
+ # @return [void]
42
+ def self.restart
43
+ Notifier.instances.each(&:restart)
44
+ Scheduler.instances.each(&:restart)
45
+ end
20
46
  end
@@ -1,17 +1,27 @@
1
1
  module GoodJob
2
2
  class Adapter
3
- EXECUTION_MODES = [:inline, :external].freeze # TODO: async
3
+ EXECUTION_MODES = [:async, :external, :inline].freeze
4
4
 
5
- def initialize(execution_mode: nil, inline: false)
6
- if inline
5
+ def initialize(execution_mode: nil, queues: nil, max_threads: nil, poll_interval: nil, scheduler: nil, notifier: nil, inline: false)
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
- @execution_mode = :inline
9
- elsif execution_mode
10
- raise ArgumentError, "execution_mode: must be one of #{EXECUTION_MODES.join(', ')}." unless EXECUTION_MODES.include?(execution_mode)
8
+ execution_mode = :inline
9
+ end
10
+
11
+ configuration = GoodJob::Configuration.new(
12
+ execution_mode: execution_mode,
13
+ queues: queues,
14
+ max_threads: max_threads,
15
+ poll_interval: poll_interval
16
+ )
17
+
18
+ @execution_mode = configuration.execution_mode
19
+ raise ArgumentError, "execution_mode: must be one of #{EXECUTION_MODES.join(', ')}." unless EXECUTION_MODES.include?(@execution_mode)
11
20
 
12
- @execution_mode = execution_mode
13
- else
14
- @execution_mode = :external
21
+ if @execution_mode == :async # rubocop:disable Style/GuardClause
22
+ @notifier = notifier || GoodJob::Notifier.new
23
+ @scheduler = scheduler || GoodJob::Scheduler.from_configuration(configuration)
24
+ @notifier.recipients << [@scheduler, :create_thread]
15
25
  end
16
26
  end
17
27
 
@@ -34,11 +44,23 @@ module GoodJob
34
44
  end
35
45
  end
36
46
 
47
+ executed_locally = execute_async? && @scheduler.create_thread(queue_name: good_job.queue_name)
48
+ Notifier.notify(queue_name: good_job.queue_name) unless executed_locally
49
+
37
50
  good_job
38
51
  end
39
52
 
40
- def shutdown(wait: true) # rubocop:disable Lint/UnusedMethodArgument
41
- nil
53
+ def shutdown(wait: true)
54
+ @notifier&.shutdown(wait: wait)
55
+ @scheduler&.shutdown(wait: wait)
56
+ end
57
+
58
+ def execute_async?
59
+ @execution_mode == :async
60
+ end
61
+
62
+ def execute_externally?
63
+ @execution_mode == :external
42
64
  end
43
65
 
44
66
  def execute_inline?
@@ -49,9 +71,5 @@ module GoodJob
49
71
  ActiveSupport::Deprecation.warn('GoodJob::Adapter::inline? is deprecated; use GoodJob::Adapter::execute_inline? instead')
50
72
  execute_inline?
51
73
  end
52
-
53
- def execute_externally?
54
- @execution_mode == :external
55
- end
56
74
  end
57
75
  end
@@ -10,47 +10,18 @@ module GoodJob
10
10
  desc: "Maximum number of threads to use for working jobs (default: ActiveRecord::Base.connection_pool.size)"
11
11
  method_option :queues,
12
12
  type: :string,
13
- banner: "queue1,queue2",
14
- desc: "Queues to work from. Separate multiple queues with commas (default: *)"
13
+ banner: "queue1,queue2(;queue3,queue4:5;-queue1,queue2)",
14
+ desc: "Queues to work from. Separate multiple queues with commas; exclude queues with a leading minus; separate isolated execution pools with semicolons and threads with colons (default: *)"
15
15
  method_option :poll_interval,
16
16
  type: :numeric,
17
17
  desc: "Interval between polls for available jobs in seconds (default: 1)"
18
18
  def start
19
- require RAILS_ENVIRONMENT_RB
19
+ set_up_application!
20
20
 
21
- max_threads = (
22
- options[:max_threads] ||
23
- ENV['GOOD_JOB_MAX_THREADS'] ||
24
- ENV['RAILS_MAX_THREADS'] ||
25
- ActiveRecord::Base.connection_pool.size
26
- ).to_i
27
-
28
- queue_names = (
29
- options[:queues] ||
30
- ENV['GOOD_JOB_QUEUES'] ||
31
- '*'
32
- ).split(',').map(&:strip)
33
-
34
- poll_interval = (
35
- options[:poll_interval] ||
36
- ENV['GOOD_JOB_POLL_INTERVAL']
37
- ).to_i
38
-
39
- job_query = GoodJob::Job.all.priority_ordered
40
- queue_names_without_all = queue_names.reject { |q| q == '*' }
41
- job_query = job_query.where(queue_name: queue_names_without_all) unless queue_names_without_all.size.zero?
42
- job_performer = GoodJob::Performer.new(job_query, :perform_with_advisory_lock)
43
-
44
- $stdout.puts "GoodJob worker starting with max_threads=#{max_threads} on queues=#{queue_names.join(',')}"
45
-
46
- timer_options = {}
47
- timer_options[:execution_interval] = poll_interval if poll_interval.positive?
48
-
49
- pool_options = {
50
- max_threads: max_threads,
51
- }
52
-
53
- scheduler = GoodJob::Scheduler.new(job_performer, timer_options: timer_options, pool_options: pool_options)
21
+ notifier = GoodJob::Notifier.new
22
+ configuration = GoodJob::Configuration.new(options)
23
+ scheduler = GoodJob::Scheduler.from_configuration(configuration)
24
+ notifier.recipients << [scheduler, :create_thread]
54
25
 
55
26
  @stop_good_job_executable = false
56
27
  %w[INT TERM].each do |signal|
@@ -59,27 +30,40 @@ module GoodJob
59
30
 
60
31
  Kernel.loop do
61
32
  sleep 0.1
62
- break if @stop_good_job_executable || scheduler.shutdown?
33
+ break if @stop_good_job_executable || scheduler.shutdown? || notifier.shutdown?
63
34
  end
64
35
 
65
- $stdout.puts "\nFinishing GoodJob's current jobs before exiting..."
36
+ notifier.shutdown
66
37
  scheduler.shutdown
67
- $stdout.puts "GoodJob's jobs finished, exiting..."
68
38
  end
69
39
 
40
+ default_task :start
41
+
70
42
  desc :cleanup_preserved_jobs, "Delete preserved job records"
71
43
  method_option :before_seconds_ago,
72
44
  type: :numeric,
73
45
  default: 24 * 60 * 60,
74
46
  desc: "Delete records finished more than this many seconds ago"
47
+
75
48
  def cleanup_preserved_jobs
76
- require RAILS_ENVIRONMENT_RB
49
+ set_up_application!
77
50
 
78
51
  timestamp = Time.current - options[:before_seconds_ago]
79
- result = GoodJob::Job.finished(timestamp).delete_all
80
- $stdout.puts "Deleted #{result} preserved #{'job'.pluralize(result)} finished before #{timestamp}."
52
+ ActiveSupport::Notifications.instrument("cleanup_preserved_jobs.good_job", { before_seconds_ago: options[:before_seconds_ago], timestamp: timestamp }) do |payload|
53
+ deleted_records_count = GoodJob::Job.finished(timestamp).delete_all
54
+
55
+ payload[:deleted_records_count] = deleted_records_count
56
+ end
81
57
  end
82
58
 
83
- default_task :start
59
+ no_commands do
60
+ def set_up_application!
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
66
+ end
67
+ end
84
68
  end
85
69
  end
@@ -0,0 +1,55 @@
1
+ module GoodJob
2
+ class Configuration
3
+ attr_reader :options, :env
4
+
5
+ def initialize(options, env: ENV)
6
+ @options = options
7
+ @env = env
8
+ end
9
+
10
+ def execution_mode(default: :external)
11
+ if options[:execution_mode]
12
+ options[:execution_mode]
13
+ elsif env['GOOD_JOB_EXECUTION_MODE'].present?
14
+ env['GOOD_JOB_EXECUTION_MODE'].to_sym
15
+ else
16
+ default
17
+ end
18
+ end
19
+
20
+ def rails_execution_mode
21
+ if execution_mode(default: nil)
22
+ execution_mode
23
+ elsif Rails.env.development?
24
+ :inline
25
+ elsif Rails.env.test?
26
+ :inline
27
+ else
28
+ :external
29
+ end
30
+ end
31
+
32
+ def max_threads
33
+ (
34
+ options[:max_threads] ||
35
+ env['GOOD_JOB_MAX_THREADS'] ||
36
+ env['RAILS_MAX_THREADS'] ||
37
+ ActiveRecord::Base.connection_pool.size
38
+ ).to_i
39
+ end
40
+
41
+ def queue_string
42
+ options[:queues] ||
43
+ env['GOOD_JOB_QUEUES'] ||
44
+ '*'
45
+ end
46
+
47
+ def poll_interval
48
+ (
49
+ options[:poll_interval] ||
50
+ env['GOOD_JOB_POLL_INTERVAL'] ||
51
+ 1
52
+ ).to_i
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,35 @@
1
+ module GoodJob
2
+ # Thread-local attributes for passing values from Instrumentation.
3
+ # (Cannot use ActiveSupport::CurrentAttributes because ActiveJob resets it)
4
+
5
+ module CurrentExecution
6
+ # @!attribute [rw] error_on_retry
7
+ # @!scope class
8
+ # Error captured by retry_on
9
+ # @return [Exception, nil]
10
+ thread_mattr_accessor :error_on_retry
11
+
12
+ # @!attribute [rw] error_on_discard
13
+ # @!scope class
14
+ # Error captured by discard_on
15
+ # @return [Exception, nil]
16
+ thread_mattr_accessor :error_on_discard
17
+
18
+ # Resets attributes
19
+ # @return [void]
20
+ def self.reset
21
+ self.error_on_retry = nil
22
+ self.error_on_discard = nil
23
+ end
24
+
25
+ # @return [Integer] Current process ID
26
+ def self.process_id
27
+ Process.pid
28
+ end
29
+
30
+ # @return [String] Current thread name
31
+ def self.thread_name
32
+ (Thread.current.name || Thread.current.object_id).to_s
33
+ end
34
+ end
35
+ 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)
@@ -18,15 +37,26 @@ module GoodJob
18
37
  end
19
38
  end)
20
39
  scope :only_scheduled, -> { where(arel_table['scheduled_at'].lteq(Time.current)).or(where(scheduled_at: nil)) }
21
- scope :priority_ordered, -> { order(priority: :desc) }
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) }
42
+ scope :queue_string, (lambda do |string|
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])
51
+ end
52
+ end)
23
53
 
24
54
  def self.perform_with_advisory_lock
25
55
  good_job = nil
26
56
  result = nil
27
57
  error = nil
28
58
 
29
- unfinished.only_scheduled.limit(1).with_advisory_lock do |good_jobs|
59
+ unfinished.priority_ordered.only_scheduled.limit(1).with_advisory_lock do |good_jobs|
30
60
  good_job = good_jobs.first
31
61
  break unless good_job
32
62
 
@@ -43,7 +73,7 @@ module GoodJob
43
73
  queue_name: active_job.queue_name.presence || DEFAULT_QUEUE_NAME,
44
74
  priority: active_job.priority || DEFAULT_PRIORITY,
45
75
  serialized_params: active_job.serialize,
46
- scheduled_at: scheduled_at || Time.current,
76
+ scheduled_at: scheduled_at,
47
77
  create_with_advisory_lock: create_with_advisory_lock
48
78
  )
49
79
 
@@ -59,11 +89,11 @@ module GoodJob
59
89
  def perform(destroy_after: !GoodJob.preserve_job_records, reperform_on_standard_error: GoodJob.reperform_jobs_on_standard_error)
60
90
  raise PreviouslyPerformedError, 'Cannot perform a job that has already been performed' if finished_at
61
91
 
92
+ GoodJob::CurrentExecution.reset
62
93
  result = nil
63
94
  rescued_error = nil
64
95
  error = nil
65
96
 
66
- ActiveSupport::Notifications.instrument("before_perform_job.good_job", { good_job: self })
67
97
  self.performed_at = Time.current
68
98
  save! unless destroy_after
69
99
 
@@ -72,18 +102,23 @@ module GoodJob
72
102
  )
73
103
 
74
104
  begin
75
- ActiveSupport::Notifications.instrument("perform_job.good_job", { good_job: self }) do
105
+ ActiveSupport::Notifications.instrument("perform_job.good_job", { good_job: self, process_id: GoodJob::CurrentExecution.process_id, thread_name: GoodJob::CurrentExecution.thread_name }) do
76
106
  result = ActiveJob::Base.execute(params)
77
107
  end
78
108
  rescue StandardError => e
79
109
  rescued_error = e
80
110
  end
81
111
 
112
+ retry_or_discard_error = GoodJob::CurrentExecution.error_on_retry ||
113
+ GoodJob::CurrentExecution.error_on_discard
114
+
82
115
  if rescued_error
83
116
  error = rescued_error
84
117
  elsif result.is_a?(Exception)
85
118
  error = result
86
119
  result = nil
120
+ elsif retry_or_discard_error
121
+ error = retry_or_discard_error
87
122
  end
88
123
 
89
124
  error_message = "#{error.class}: #{error.message}" if error
@@ -0,0 +1,167 @@
1
+ module GoodJob
2
+ class LogSubscriber < ActiveSupport::LogSubscriber
3
+ def create(event)
4
+ good_job = event.payload[:good_job]
5
+
6
+ debug do
7
+ "GoodJob created job resource with id #{good_job.id}"
8
+ end
9
+ end
10
+
11
+ def timer_task_finished(event)
12
+ exception = event.payload[:error]
13
+ return unless exception
14
+
15
+ error do
16
+ "GoodJob error: #{exception}\n #{exception.backtrace}"
17
+ end
18
+ end
19
+
20
+ def job_finished(event)
21
+ exception = event.payload[:error]
22
+ return unless exception
23
+
24
+ error do
25
+ "GoodJob error: #{exception}\n #{exception.backtrace}"
26
+ end
27
+ end
28
+
29
+ def scheduler_create_pools(event)
30
+ max_threads = event.payload[:max_threads]
31
+ poll_interval = event.payload[:poll_interval]
32
+ performer_name = event.payload[:performer_name]
33
+ process_id = event.payload[:process_id]
34
+
35
+ info(tags: [process_id]) do
36
+ "GoodJob started scheduler with queues=#{performer_name} max_threads=#{max_threads} poll_interval=#{poll_interval}."
37
+ end
38
+ end
39
+
40
+ def scheduler_shutdown_start(event)
41
+ process_id = event.payload[:process_id]
42
+
43
+ info(tags: [process_id]) do
44
+ "GoodJob shutting down scheduler..."
45
+ end
46
+ end
47
+
48
+ def scheduler_shutdown(event)
49
+ process_id = event.payload[:process_id]
50
+
51
+ info(tags: [process_id]) do
52
+ "GoodJob scheduler is shut down."
53
+ end
54
+ end
55
+
56
+ def scheduler_restart_pools(event)
57
+ process_id = event.payload[:process_id]
58
+
59
+ info(tags: [process_id]) do
60
+ "GoodJob scheduler has restarted."
61
+ end
62
+ end
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
+
102
+ def cleanup_preserved_jobs(event)
103
+ timestamp = event.payload[:timestamp]
104
+ deleted_records_count = event.payload[:deleted_records_count]
105
+
106
+ info do
107
+ "GoodJob deleted #{deleted_records_count} preserved #{'job'.pluralize(deleted_records_count)} finished before #{timestamp}."
108
+ end
109
+ end
110
+
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
130
+
131
+ def 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
153
+ end
154
+
155
+ %w(info debug warn error fatal unknown).each do |level|
156
+ class_eval <<-METHOD, __FILE__, __LINE__ + 1
157
+ def #{level}(progname = nil, tags: [], &block)
158
+ return unless logger
159
+
160
+ tag_logger(*tags) do
161
+ logger.#{level}(progname, &block)
162
+ end
163
+ end
164
+ METHOD
165
+ end
166
+ end
167
+ end