good_job 1.0.0 → 1.1.1

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)",
14
+ desc: "Queues to work from. Separate multiple queues with commas; 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,28 @@ 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
+ queue_names_without_all = (string.presence || '*').split(',').map(&:strip).reject { |q| q == '*' }
25
+ where(queue_name: queue_names_without_all) unless queue_names_without_all.size.zero?
26
+ end)
23
27
 
24
- def self.perform_with_advisory_lock(destroy_after: !GoodJob.preserve_job_records)
28
+ def self.perform_with_advisory_lock
25
29
  good_job = nil
26
30
  result = nil
27
31
  error = nil
28
32
 
29
- unfinished.only_scheduled.limit(1).with_advisory_lock do |good_jobs|
33
+ unfinished.priority_ordered.only_scheduled.limit(1).with_advisory_lock do |good_jobs|
30
34
  good_job = good_jobs.first
31
35
  break unless good_job
32
36
 
33
- result, error = good_job.perform(destroy_after: destroy_after)
37
+ result, error = good_job.perform
34
38
  end
35
39
 
36
40
  [good_job, result, error] if good_job
37
41
  end
38
42
 
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
43
  def self.enqueue(active_job, scheduled_at: nil, create_with_advisory_lock: false)
48
44
  good_job = nil
49
45
  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 +60,49 @@ module GoodJob
64
60
  good_job
65
61
  end
66
62
 
67
- def perform(destroy_after: true)
63
+ def perform(destroy_after: !GoodJob.preserve_job_records, reperform_on_standard_error: GoodJob.reperform_jobs_on_standard_error)
68
64
  raise PreviouslyPerformedError, 'Cannot perform a job that has already been performed' if finished_at
69
65
 
70
66
  result = nil
67
+ rescued_error = nil
71
68
  error = nil
72
69
 
73
70
  ActiveSupport::Notifications.instrument("before_perform_job.good_job", { good_job: self })
74
71
  self.performed_at = Time.current
75
72
  save! unless destroy_after
76
73
 
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
74
+ params = serialized_params.merge(
75
+ "provider_job_id" => id
76
+ )
77
+
78
+ begin
79
+ ActiveSupport::Notifications.instrument("perform_job.good_job", { good_job: self }) do
82
80
  result = ActiveJob::Base.execute(params)
83
- rescue StandardError => e
84
- error = e
85
81
  end
82
+ rescue StandardError => e
83
+ rescued_error = e
86
84
  end
87
85
 
88
- if error.nil? && result.is_a?(Exception)
86
+ if rescued_error
87
+ error = rescued_error
88
+ elsif result.is_a?(Exception)
89
89
  error = result
90
90
  result = nil
91
91
  end
92
92
 
93
93
  error_message = "#{error.class}: #{error.message}" if error
94
94
  self.error = error_message
95
- self.finished_at = Time.current
96
95
 
97
- if destroy_after
98
- destroy!
99
- else
96
+ if rescued_error && reperform_on_standard_error
100
97
  save!
98
+ else
99
+ self.finished_at = Time.current
100
+
101
+ if destroy_after
102
+ destroy!
103
+ else
104
+ save!
105
+ end
101
106
  end
102
107
 
103
108
  [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