good_job 1.0.1 → 1.1.2

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,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
@@ -0,0 +1,24 @@
1
+ require 'rails/generators'
2
+ require 'rails/generators/active_record'
3
+
4
+ module GoodJob
5
+ class InstallGenerator < Rails::Generators::Base
6
+ include Rails::Generators::Migration
7
+
8
+ class << self
9
+ delegate :next_migration_number, to: ActiveRecord::Generators::Base
10
+ end
11
+
12
+ source_paths << File.join(File.dirname(__FILE__), "templates")
13
+
14
+ def create_migration_file
15
+ migration_template 'migration.rb.erb', 'db/migrate/create_good_jobs.rb', migration_version: migration_version
16
+ end
17
+
18
+ private
19
+
20
+ def migration_version
21
+ "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,20 @@
1
+ class CreateGoodJobs < ActiveRecord::Migration<%= migration_version %>
2
+ def change
3
+ enable_extension 'pgcrypto'
4
+
5
+ create_table :good_jobs, id: :uuid do |t|
6
+ t.text :queue_name
7
+ t.integer :priority
8
+ t.jsonb :serialized_params
9
+ t.timestamp :scheduled_at
10
+ t.timestamp :performed_at
11
+ t.timestamp :finished_at
12
+ t.text :error
13
+
14
+ t.timestamps
15
+ end
16
+
17
+ add_index :good_jobs, :scheduled_at, where: "(finished_at IS NULL)"
18
+ add_index :good_jobs, [:queue_name, :scheduled_at], where: "(finished_at IS NULL)"
19
+ end
20
+ end
@@ -1,10 +1,12 @@
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'
@@ -12,8 +14,10 @@ require 'good_job/performer'
12
14
  require 'active_job/queue_adapters/good_job_adapter'
13
15
 
14
16
  module GoodJob
17
+ cattr_accessor :logger, default: ActiveSupport::TaggedLogging.new(ActiveSupport::Logger.new(STDOUT))
15
18
  mattr_accessor :preserve_job_records, default: false
16
- include Logging
19
+ mattr_accessor :reperform_jobs_on_standard_error, default: true
20
+ mattr_accessor :on_thread_error, default: nil
17
21
 
18
22
  ActiveSupport.run_load_hooks(:good_job, self)
19
23
  end
@@ -1,18 +1,26 @@
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, max_threads: nil, poll_interval: nil, scheduler: 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)
11
-
12
- @execution_mode = execution_mode
13
- else
14
- @execution_mode = :external
8
+ execution_mode = :inline
15
9
  end
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)
19
+
20
+ @execution_mode = configuration.execution_mode
21
+
22
+ @scheduler = scheduler
23
+ @scheduler = GoodJob::Scheduler.from_configuration(configuration) if @execution_mode == :async && @scheduler.blank?
16
24
  end
17
25
 
18
26
  def enqueue(active_job)
@@ -34,11 +42,21 @@ module GoodJob
34
42
  end
35
43
  end
36
44
 
45
+ @scheduler.create_thread if execute_async?
46
+
37
47
  good_job
38
48
  end
39
49
 
40
- def shutdown(wait: true) # rubocop:disable Lint/UnusedMethodArgument
41
- nil
50
+ def shutdown(wait: true)
51
+ @scheduler&.shutdown(wait: wait)
52
+ end
53
+
54
+ def execute_async?
55
+ @execution_mode == :async
56
+ end
57
+
58
+ def execute_externally?
59
+ @execution_mode == :external
42
60
  end
43
61
 
44
62
  def execute_inline?
@@ -49,9 +67,5 @@ module GoodJob
49
67
  ActiveSupport::Deprecation.warn('GoodJob::Adapter::inline? is deprecated; use GoodJob::Adapter::execute_inline? instead')
50
68
  execute_inline?
51
69
  end
52
-
53
- def execute_externally?
54
- @execution_mode == :external
55
- end
56
70
  end
57
71
  end
@@ -10,53 +10,16 @@ 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
-
43
- performer_method = if GoodJob.preserve_job_records
44
- :perform_with_advisory_lock_and_preserve_job_records
45
- else
46
- :perform_with_advisory_lock_and_destroy_job_records
47
- end
48
- job_performer = GoodJob::Performer.new(job_query, performer_method)
49
-
50
- $stdout.puts "GoodJob worker starting with max_threads=#{max_threads} on queues=#{queue_names.join(',')}"
51
-
52
- timer_options = {}
53
- timer_options[:execution_interval] = poll_interval if poll_interval.positive?
54
-
55
- pool_options = {
56
- max_threads: max_threads,
57
- }
58
-
59
- scheduler = GoodJob::Scheduler.new(job_performer, timer_options: timer_options, pool_options: pool_options)
21
+ configuration = Configuration.new(options, env: ENV)
22
+ scheduler = Scheduler.from_configuration(configuration)
60
23
 
61
24
  @stop_good_job_executable = false
62
25
  %w[INT TERM].each do |signal|
@@ -68,24 +31,31 @@ module GoodJob
68
31
  break if @stop_good_job_executable || scheduler.shutdown?
69
32
  end
70
33
 
71
- $stdout.puts "\nFinishing GoodJob's current jobs before exiting..."
72
34
  scheduler.shutdown
73
- $stdout.puts "GoodJob's jobs finished, exiting..."
74
35
  end
75
36
 
37
+ default_task :start
38
+
76
39
  desc :cleanup_preserved_jobs, "Delete preserved job records"
77
40
  method_option :before_seconds_ago,
78
41
  type: :numeric,
79
42
  default: 24 * 60 * 60,
80
43
  desc: "Delete records finished more than this many seconds ago"
81
44
  def cleanup_preserved_jobs
82
- require RAILS_ENVIRONMENT_RB
45
+ set_up_application!
83
46
 
84
47
  timestamp = Time.current - options[:before_seconds_ago]
85
- result = GoodJob::Job.finished(timestamp).delete_all
86
- $stdout.puts "Deleted #{result} preserved #{'job'.pluralize(result)} finished before #{timestamp}."
48
+ ActiveSupport::Notifications.instrument("cleanup_preserved_jobs.good_job", { before_seconds_ago: options[:before_seconds_ago], timestamp: timestamp }) do |payload|
49
+ deleted_records_count = GoodJob::Job.finished(timestamp).delete_all
50
+
51
+ payload[:deleted_records_count] = deleted_records_count
52
+ end
87
53
  end
88
54
 
89
- default_task :start
55
+ no_commands do
56
+ def set_up_application!
57
+ require RAILS_ENVIRONMENT_RB
58
+ end
59
+ end
90
60
  end
91
61
  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
@@ -18,32 +18,41 @@ module GoodJob
18
18
  end
19
19
  end)
20
20
  scope :only_scheduled, -> { where(arel_table['scheduled_at'].lteq(Time.current)).or(where(scheduled_at: nil)) }
21
- scope :priority_ordered, -> { order(priority: :desc) }
21
+ scope :priority_ordered, -> { order('priority DESC NULLS LAST') }
22
22
  scope :finished, ->(timestamp = nil) { timestamp ? where(arel_table['finished_at'].lteq(timestamp)) : where.not(finished_at: nil) }
23
+ scope :queue_string, (lambda do |string|
24
+ string = string.presence || '*'
23
25
 
24
- def self.perform_with_advisory_lock(destroy_after: !GoodJob.preserve_job_records)
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)
38
+ end
39
+ end)
40
+
41
+ def self.perform_with_advisory_lock
25
42
  good_job = nil
26
43
  result = nil
27
44
  error = nil
28
45
 
29
- unfinished.only_scheduled.limit(1).with_advisory_lock do |good_jobs|
46
+ unfinished.priority_ordered.only_scheduled.limit(1).with_advisory_lock do |good_jobs|
30
47
  good_job = good_jobs.first
31
48
  break unless good_job
32
49
 
33
- result, error = good_job.perform(destroy_after: destroy_after)
50
+ result, error = good_job.perform
34
51
  end
35
52
 
36
53
  [good_job, result, error] if good_job
37
54
  end
38
55
 
39
- def self.perform_with_advisory_lock_and_preserve_job_records
40
- perform_with_advisory_lock(destroy_after: false)
41
- end
42
-
43
- def self.perform_with_advisory_lock_and_destroy_job_records
44
- perform_with_advisory_lock(destroy_after: true)
45
- end
46
-
47
56
  def self.enqueue(active_job, scheduled_at: nil, create_with_advisory_lock: false)
48
57
  good_job = nil
49
58
  ActiveSupport::Notifications.instrument("enqueue_job.good_job", { active_job: active_job, scheduled_at: scheduled_at, create_with_advisory_lock: create_with_advisory_lock }) do |instrument_payload|
@@ -64,40 +73,49 @@ module GoodJob
64
73
  good_job
65
74
  end
66
75
 
67
- def perform(destroy_after: true)
76
+ def perform(destroy_after: !GoodJob.preserve_job_records, reperform_on_standard_error: GoodJob.reperform_jobs_on_standard_error)
68
77
  raise PreviouslyPerformedError, 'Cannot perform a job that has already been performed' if finished_at
69
78
 
70
79
  result = nil
80
+ rescued_error = nil
71
81
  error = nil
72
82
 
73
83
  ActiveSupport::Notifications.instrument("before_perform_job.good_job", { good_job: self })
74
84
  self.performed_at = Time.current
75
85
  save! unless destroy_after
76
86
 
77
- ActiveSupport::Notifications.instrument("perform_job.good_job", { good_job: self }) do
78
- params = serialized_params.merge(
79
- "provider_job_id" => id
80
- )
81
- begin
87
+ params = serialized_params.merge(
88
+ "provider_job_id" => id
89
+ )
90
+
91
+ begin
92
+ ActiveSupport::Notifications.instrument("perform_job.good_job", { good_job: self }) do
82
93
  result = ActiveJob::Base.execute(params)
83
- rescue StandardError => e
84
- error = e
85
94
  end
95
+ rescue StandardError => e
96
+ rescued_error = e
86
97
  end
87
98
 
88
- if error.nil? && result.is_a?(Exception)
99
+ if rescued_error
100
+ error = rescued_error
101
+ elsif result.is_a?(Exception)
89
102
  error = result
90
103
  result = nil
91
104
  end
92
105
 
93
106
  error_message = "#{error.class}: #{error.message}" if error
94
107
  self.error = error_message
95
- self.finished_at = Time.current
96
108
 
97
- if destroy_after
98
- destroy!
99
- else
109
+ if rescued_error && reperform_on_standard_error
100
110
  save!
111
+ else
112
+ self.finished_at = Time.current
113
+
114
+ if destroy_after
115
+ destroy!
116
+ else
117
+ save!
118
+ end
101
119
  end
102
120
 
103
121
  [result, error]
@@ -0,0 +1,110 @@
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_and_stdout(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_and_stdout(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_and_stdout(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_and_stdout(tags: [process_id]) do
60
+ "GoodJob scheduler has restarted."
61
+ end
62
+ end
63
+
64
+ def cleanup_preserved_jobs(event)
65
+ timestamp = event.payload[:timestamp]
66
+ deleted_records_count = event.payload[:deleted_records_count]
67
+
68
+ info_and_stdout do
69
+ "GoodJob deleted #{deleted_records_count} preserved #{'job'.pluralize(deleted_records_count)} finished before #{timestamp}."
70
+ end
71
+ end
72
+
73
+ private
74
+
75
+ def logger
76
+ GoodJob.logger
77
+ end
78
+
79
+ %w(info debug warn error fatal unknown).each do |level|
80
+ class_eval <<-METHOD, __FILE__, __LINE__ + 1
81
+ def #{level}(progname = nil, tags: [], &block)
82
+ return unless logger
83
+
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
90
+ logger.#{level}(progname, &block)
91
+ end
92
+ end
93
+ METHOD
94
+ 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
+ end
110
+ end