good_job 2.5.0 → 2.7.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +69 -0
  3. data/README.md +58 -1
  4. data/engine/app/assets/scripts.js +1 -0
  5. data/engine/app/assets/style.css +5 -0
  6. data/engine/app/assets/vendor/chartjs/chart.min.js +13 -0
  7. data/engine/app/assets/vendor/rails_ujs.js +747 -0
  8. data/engine/app/charts/good_job/scheduled_by_queue_chart.rb +69 -0
  9. data/engine/app/controllers/good_job/assets_controller.rb +8 -4
  10. data/engine/app/controllers/good_job/base_controller.rb +19 -0
  11. data/engine/app/controllers/good_job/cron_entries_controller.rb +19 -0
  12. data/engine/app/filters/good_job/base_filter.rb +12 -54
  13. data/engine/app/filters/good_job/executions_filter.rb +9 -8
  14. data/engine/app/filters/good_job/jobs_filter.rb +9 -8
  15. data/engine/app/views/good_job/cron_entries/index.html.erb +51 -0
  16. data/engine/app/views/good_job/cron_entries/show.html.erb +4 -0
  17. data/engine/app/views/good_job/{shared/_executions_table.erb → executions/_table.erb} +1 -1
  18. data/engine/app/views/good_job/executions/index.html.erb +2 -2
  19. data/engine/app/views/good_job/{shared/_jobs_table.erb → jobs/_table.erb} +4 -4
  20. data/engine/app/views/good_job/jobs/index.html.erb +2 -2
  21. data/engine/app/views/good_job/jobs/show.html.erb +2 -2
  22. data/engine/app/views/good_job/shared/_chart.erb +19 -46
  23. data/engine/app/views/good_job/shared/_filter.erb +27 -13
  24. data/engine/app/views/good_job/shared/icons/_play.html.erb +4 -0
  25. data/engine/app/views/layouts/good_job/base.html.erb +6 -4
  26. data/engine/config/routes.rb +10 -3
  27. data/{engine/app/models → lib}/good_job/active_job_job.rb +2 -19
  28. data/lib/good_job/cli.rb +8 -0
  29. data/lib/good_job/configuration.rb +8 -1
  30. data/lib/good_job/cron_entry.rb +75 -4
  31. data/lib/good_job/cron_manager.rb +1 -5
  32. data/lib/good_job/current_thread.rb +26 -8
  33. data/lib/good_job/execution.rb +16 -31
  34. data/lib/good_job/filterable.rb +42 -0
  35. data/lib/good_job/notifier.rb +17 -7
  36. data/lib/good_job/probe_server.rb +51 -0
  37. data/lib/good_job/version.rb +1 -1
  38. metadata +41 -21
  39. data/engine/app/assets/vendor/chartist/chartist.css +0 -613
  40. data/engine/app/assets/vendor/chartist/chartist.js +0 -4516
  41. data/engine/app/controllers/good_job/cron_schedules_controller.rb +0 -9
  42. data/engine/app/views/good_job/cron_schedules/index.html.erb +0 -72
@@ -1,7 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
  GoodJob::Engine.routes.draw do
3
3
  root to: 'executions#index'
4
- resources :cron_schedules, only: %i[index]
4
+
5
+ resources :cron_entries, only: %i[index show] do
6
+ member do
7
+ post :enqueue
8
+ end
9
+ end
10
+
5
11
  resources :jobs, only: %i[index show] do
6
12
  member do
7
13
  put :discard
@@ -14,13 +20,14 @@ GoodJob::Engine.routes.draw do
14
20
  scope controller: :assets do
15
21
  constraints(format: :css) do
16
22
  get :bootstrap, action: :bootstrap_css
17
- get :chartist, action: :chartist_css
18
23
  get :style, action: :style_css
19
24
  end
20
25
 
21
26
  constraints(format: :js) do
22
27
  get :bootstrap, action: :bootstrap_js
23
- get :chartist, action: :chartist_js
28
+ get :rails_ujs, action: :rails_ujs_js
29
+ get :chartjs, action: :chartjs_js
30
+ get :scripts, action: :scripts_js
24
31
  end
25
32
  end
26
33
  end
@@ -8,7 +8,8 @@ module GoodJob
8
8
  # @!parse
9
9
  # class ActiveJob < ActiveRecord::Base; end
10
10
  class ActiveJobJob < Object.const_get(GoodJob.active_record_parent_class)
11
- include GoodJob::Lockable
11
+ include Filterable
12
+ include Lockable
12
13
 
13
14
  # Raised when an inappropriate action is applied to a Job based on its state.
14
15
  ActionForStateMismatchError = Class.new(StandardError)
@@ -47,24 +48,6 @@ module GoodJob
47
48
  # Errored but will not be retried
48
49
  scope :discarded, -> { where.not(finished_at: nil).where.not(error: nil) }
49
50
 
50
- # Get Jobs in display order with optional keyset pagination.
51
- # @!method display_all(after_scheduled_at: nil, after_id: nil)
52
- # @!scope class
53
- # @param after_scheduled_at [DateTime, String, nil]
54
- # Display records scheduled after this time for keyset pagination
55
- # @param after_id [Numeric, String, nil]
56
- # Display records after this ID for keyset pagination
57
- # @return [ActiveRecord::Relation]
58
- scope :display_all, (lambda do |after_scheduled_at: nil, after_id: nil|
59
- query = order(Arel.sql('COALESCE(scheduled_at, created_at) DESC, id DESC'))
60
- if after_scheduled_at.present? && after_id.present?
61
- query = query.where(Arel.sql('(COALESCE(scheduled_at, created_at), id) < (:after_scheduled_at, :after_id)'), after_scheduled_at: after_scheduled_at, after_id: after_id)
62
- elsif after_scheduled_at.present?
63
- query = query.where(Arel.sql('(COALESCE(scheduled_at, created_at)) < (:after_scheduled_at)'), after_scheduled_at: after_scheduled_at)
64
- end
65
- query
66
- end)
67
-
68
51
  # The job's ActiveJob UUID
69
52
  # @return [String]
70
53
  def id
data/lib/good_job/cli.rb CHANGED
@@ -79,6 +79,9 @@ module GoodJob
79
79
  method_option :pidfile,
80
80
  type: :string,
81
81
  desc: "Path to write daemonized Process ID (env var: GOOD_JOB_PIDFILE, default: tmp/pids/good_job.pid)"
82
+ method_option :probe_port,
83
+ type: :numeric,
84
+ desc: "Port for http health check (env var: GOOD_JOB_PROBE_PORT, default: nil)"
82
85
 
83
86
  def start
84
87
  set_up_application!
@@ -93,6 +96,10 @@ module GoodJob
93
96
  poller.recipients << [scheduler, :create_thread]
94
97
 
95
98
  cron_manager = GoodJob::CronManager.new(configuration.cron_entries, start_on_initialize: true) if configuration.enable_cron?
99
+ if configuration.probe_port
100
+ probe_server = GoodJob::ProbeServer.new(port: configuration.probe_port)
101
+ probe_server.start
102
+ end
96
103
 
97
104
  @stop_good_job_executable = false
98
105
  %w[INT TERM].each do |signal|
@@ -106,6 +113,7 @@ module GoodJob
106
113
 
107
114
  executors = [notifier, poller, cron_manager, scheduler].compact
108
115
  GoodJob._shutdown_all(executors, timeout: configuration.shutdown_timeout)
116
+ probe_server&.stop
109
117
  end
110
118
 
111
119
  default_task :start
@@ -157,7 +157,7 @@ module GoodJob
157
157
  alias enable_cron? enable_cron
158
158
 
159
159
  def cron
160
- env_cron = JSON.parse(ENV['GOOD_JOB_CRON']) if ENV['GOOD_JOB_CRON'].present?
160
+ env_cron = JSON.parse(ENV['GOOD_JOB_CRON'], symbolize_names: true) if ENV['GOOD_JOB_CRON'].present?
161
161
 
162
162
  options[:cron] ||
163
163
  rails_config[:cron] ||
@@ -195,6 +195,13 @@ module GoodJob
195
195
  Rails.application.root.join('tmp', 'pids', 'good_job.pid')
196
196
  end
197
197
 
198
+ # Port of the probe server
199
+ # @return [nil,Integer]
200
+ def probe_port
201
+ options[:probe_port] ||
202
+ env['GOOD_JOB_PROBE_PORT']
203
+ end
204
+
198
205
  private
199
206
 
200
207
  def rails_config
@@ -12,14 +12,29 @@ module GoodJob # :nodoc:
12
12
 
13
13
  attr_reader :params
14
14
 
15
+ def self.all(configuration: nil)
16
+ configuration ||= GoodJob::Configuration.new({})
17
+ configuration.cron_entries
18
+ end
19
+
20
+ def self.find(key, configuration: nil)
21
+ all(configuration: configuration).find { |entry| entry.key == key.to_sym }.tap do |cron_entry|
22
+ raise ActiveRecord::RecordNotFound unless cron_entry
23
+ end
24
+ end
25
+
15
26
  def initialize(params = {})
16
- @params = params.with_indifferent_access
27
+ @params = params
28
+
29
+ raise ArgumentError, "Invalid cron format: '#{cron}'" unless fugit.instance_of?(Fugit::Cron)
17
30
  end
18
31
 
19
32
  def key
20
33
  params.fetch(:key)
21
34
  end
35
+
22
36
  alias id key
37
+ alias to_param key
23
38
 
24
39
  def job_class
25
40
  params.fetch(:class)
@@ -42,16 +57,61 @@ module GoodJob # :nodoc:
42
57
  end
43
58
 
44
59
  def next_at
45
- fugit = Fugit::Cron.parse(cron)
46
60
  fugit.next_time.to_t
47
61
  end
48
62
 
49
- def enqueue
50
- job_class.constantize.set(set_value).perform_later(*args_value)
63
+ def schedule
64
+ fugit.original
65
+ end
66
+
67
+ def fugit
68
+ @_fugit ||= Fugit.parse(cron)
69
+ end
70
+
71
+ def jobs
72
+ GoodJob::ActiveJobJob.where(cron_key: key)
73
+ end
74
+
75
+ def last_at
76
+ return if last_job.blank?
77
+
78
+ if GoodJob::ActiveJobJob.column_names.include?('cron_at')
79
+ (last_job.cron_at || last_job.created_at).localtime
80
+ else
81
+ last_job.created_at
82
+ end
83
+ end
84
+
85
+ def enqueue(cron_at = nil)
86
+ GoodJob::CurrentThread.within do |current_thread|
87
+ current_thread.cron_key = key
88
+ current_thread.cron_at = cron_at
89
+
90
+ job_class.constantize.set(set_value).perform_later(*args_value)
91
+ end
51
92
  rescue ActiveRecord::RecordNotUnique
52
93
  false
53
94
  end
54
95
 
96
+ def last_job
97
+ if GoodJob::ActiveJobJob.column_names.include?('cron_at')
98
+ jobs.order("cron_at DESC NULLS LAST").first
99
+ else
100
+ jobs.order(created_at: :asc).last
101
+ end
102
+ end
103
+
104
+ def display_properties
105
+ {
106
+ key: key,
107
+ class: job_class,
108
+ cron: schedule,
109
+ set: display_property(set),
110
+ args: display_property(args),
111
+ description: display_property(description),
112
+ }
113
+ end
114
+
55
115
  private
56
116
 
57
117
  def set_value
@@ -63,5 +123,16 @@ module GoodJob # :nodoc:
63
123
  value = args || []
64
124
  value.respond_to?(:call) ? value.call : value
65
125
  end
126
+
127
+ def display_property(value)
128
+ case value
129
+ when NilClass
130
+ "None"
131
+ when Proc
132
+ "Lambda/Callable"
133
+ else
134
+ value
135
+ end
136
+ end
66
137
  end
67
138
  end
@@ -89,11 +89,7 @@ module GoodJob # :nodoc:
89
89
  thr_scheduler.create_task(thr_cron_entry)
90
90
 
91
91
  Rails.application.executor.wrap do
92
- CurrentThread.reset
93
- CurrentThread.cron_key = thr_cron_entry.key
94
- CurrentThread.cron_at = thr_cron_at
95
-
96
- cron_entry.enqueue
92
+ cron_entry.enqueue(thr_cron_at)
97
93
  end
98
94
  end
99
95
 
@@ -5,6 +5,15 @@ module GoodJob
5
5
  # Thread-local attributes for passing values from Instrumentation.
6
6
  # (Cannot use ActiveSupport::CurrentAttributes because ActiveJob resets it)
7
7
  module CurrentThread
8
+ # Resettable accessors for thread-local values.
9
+ ACCESSORS = %i[
10
+ cron_at
11
+ cron_key
12
+ error_on_discard
13
+ error_on_retry
14
+ execution
15
+ ].freeze
16
+
8
17
  # @!attribute [rw] cron_at
9
18
  # @!scope class
10
19
  # Cron At
@@ -36,13 +45,20 @@ module GoodJob
36
45
  thread_mattr_accessor :execution
37
46
 
38
47
  # Resets attributes
48
+ # @param [Hash] values to assign
39
49
  # @return [void]
40
- def self.reset
41
- self.cron_at = nil
42
- self.cron_key = nil
43
- self.execution = nil
44
- self.error_on_discard = nil
45
- self.error_on_retry = nil
50
+ def self.reset(values = {})
51
+ ACCESSORS.each do |accessor|
52
+ send("#{accessor}=", values[accessor])
53
+ end
54
+ end
55
+
56
+ # Exports values to hash
57
+ # @return [Hash]
58
+ def self.to_h
59
+ ACCESSORS.each_with_object({}) do |accessor, hash|
60
+ hash[accessor] = send(accessor)
61
+ end
46
62
  end
47
63
 
48
64
  # @return [String] UUID of the currently executing GoodJob::Execution
@@ -60,12 +76,14 @@ module GoodJob
60
76
  (Thread.current.name || Thread.current.object_id).to_s
61
77
  end
62
78
 
79
+ # Wrap the yielded block with CurrentThread values and reset after the block
80
+ # @yield [self]
63
81
  # @return [void]
64
82
  def self.within
65
- reset
83
+ original_values = to_h
66
84
  yield(self)
67
85
  ensure
68
- reset
86
+ reset(original_values)
69
87
  end
70
88
  end
71
89
  end
@@ -6,6 +6,7 @@ module GoodJob
6
6
  # class Execution < ActiveRecord::Base; end
7
7
  class Execution < Object.const_get(GoodJob.active_record_parent_class)
8
8
  include Lockable
9
+ include Filterable
9
10
 
10
11
  # Raised if something attempts to execute a previously completed Execution again.
11
12
  PreviouslyPerformedError = Class.new(StandardError)
@@ -156,24 +157,6 @@ module GoodJob
156
157
  end
157
158
  end)
158
159
 
159
- # Get Jobs in display order with optional keyset pagination.
160
- # @!method display_all(after_scheduled_at: nil, after_id: nil)
161
- # @!scope class
162
- # @param after_scheduled_at [DateTime, String, nil]
163
- # Display records scheduled after this time for keyset pagination
164
- # @param after_id [Numeric, String, nil]
165
- # Display records after this ID for keyset pagination
166
- # @return [ActiveRecord::Relation]
167
- scope :display_all, (lambda do |after_scheduled_at: nil, after_id: nil|
168
- query = order(Arel.sql('COALESCE(scheduled_at, created_at) DESC, id DESC'))
169
- if after_scheduled_at.present? && after_id.present?
170
- query = query.where(Arel.sql('(COALESCE(scheduled_at, created_at), id) < (:after_scheduled_at, :after_id)'), after_scheduled_at: after_scheduled_at, after_id: after_id)
171
- elsif after_scheduled_at.present?
172
- query = query.where(Arel.sql('(COALESCE(scheduled_at, created_at)) < (:after_scheduled_at)'), after_scheduled_at: after_scheduled_at)
173
- end
174
- query
175
- end)
176
-
177
160
  # Finds the next eligible Execution, acquire an advisory lock related to it, and
178
161
  # executes the job.
179
162
  # @return [ExecutionResult, nil]
@@ -309,22 +292,24 @@ module GoodJob
309
292
 
310
293
  # @return [ExecutionResult]
311
294
  def execute
312
- GoodJob::CurrentThread.reset
313
- GoodJob::CurrentThread.execution = self
295
+ GoodJob::CurrentThread.within do |current_thread|
296
+ current_thread.reset
297
+ current_thread.execution = self
314
298
 
315
- # DEPRECATION: Remove deprecated `good_job:` parameter in GoodJob v3
316
- ActiveSupport::Notifications.instrument("perform_job.good_job", { good_job: self, execution: self, process_id: GoodJob::CurrentThread.process_id, thread_name: GoodJob::CurrentThread.thread_name }) do
317
- value = ActiveJob::Base.execute(active_job_data)
299
+ # DEPRECATION: Remove deprecated `good_job:` parameter in GoodJob v3
300
+ ActiveSupport::Notifications.instrument("perform_job.good_job", { good_job: self, execution: self, process_id: current_thread.process_id, thread_name: current_thread.thread_name }) do
301
+ value = ActiveJob::Base.execute(active_job_data)
318
302
 
319
- if value.is_a?(Exception)
320
- handled_error = value
321
- value = nil
322
- end
323
- handled_error ||= GoodJob::CurrentThread.error_on_retry || GoodJob::CurrentThread.error_on_discard
303
+ if value.is_a?(Exception)
304
+ handled_error = value
305
+ value = nil
306
+ end
307
+ handled_error ||= current_thread.error_on_retry || current_thread.error_on_discard
324
308
 
325
- ExecutionResult.new(value: value, handled_error: handled_error)
326
- rescue StandardError => e
327
- ExecutionResult.new(value: nil, unhandled_error: e)
309
+ ExecutionResult.new(value: value, handled_error: handled_error)
310
+ rescue StandardError => e
311
+ ExecutionResult.new(value: nil, unhandled_error: e)
312
+ end
328
313
  end
329
314
  end
330
315
  end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+ module GoodJob
3
+ # Shared methods for filtering Execution/Job records from the +good_jobs+ table.
4
+ module Filterable
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ # Get records in display order with optional keyset pagination.
9
+ # @!method display_all(after_scheduled_at: nil, after_id: nil)
10
+ # @!scope class
11
+ # @param after_scheduled_at [DateTime, String, nil]
12
+ # Display records scheduled after this time for keyset pagination
13
+ # @param after_id [Numeric, String, nil]
14
+ # Display records after this ID for keyset pagination
15
+ # @return [ActiveRecord::Relation]
16
+ scope :display_all, (lambda do |after_scheduled_at: nil, after_id: nil|
17
+ query = order(Arel.sql('COALESCE(scheduled_at, created_at) DESC, id DESC'))
18
+ if after_scheduled_at.present? && after_id.present?
19
+ query = query.where(Arel.sql('(COALESCE(scheduled_at, created_at), id) < (:after_scheduled_at, :after_id)'), after_scheduled_at: after_scheduled_at, after_id: after_id)
20
+ elsif after_scheduled_at.present?
21
+ query = query.where(Arel.sql('(COALESCE(scheduled_at, created_at)) < (:after_scheduled_at)'), after_scheduled_at: after_scheduled_at)
22
+ end
23
+ query
24
+ end)
25
+
26
+ # Search records by text query.
27
+ # @!method search_text(query)
28
+ # @!scope class
29
+ # @param query [String]
30
+ # Search Query
31
+ # @return [ActiveRecord::Relation]
32
+ scope :search_text, (lambda do |query|
33
+ query = query.to_s.strip
34
+ next if query.blank?
35
+
36
+ tsvector = "(to_tsvector('english', serialized_params) || to_tsvector('english', id::text) || to_tsvector('english', COALESCE(error, '')::text))"
37
+ where("#{tsvector} @@ to_tsquery(?)", query)
38
+ .order(sanitize_sql_for_order([Arel.sql("ts_rank(#{tsvector}, to_tsquery(?))"), query]) => 'DESC')
39
+ end)
40
+ end
41
+ end
42
+ end
@@ -25,10 +25,17 @@ module GoodJob # :nodoc:
25
25
  max_queue: 1,
26
26
  fallback_policy: :discard,
27
27
  }.freeze
28
- # Seconds to wait if database cannot be connected to
29
- RECONNECT_INTERVAL = 5
30
28
  # Seconds to block while LISTENing for a message
31
29
  WAIT_INTERVAL = 1
30
+ # Seconds to wait if database cannot be connected to
31
+ RECONNECT_INTERVAL = 5
32
+ # Connection errors that will wait {RECONNECT_INTERVAL} before reconnecting
33
+ CONNECTION_ERRORS = %w[
34
+ ActiveRecord::ConnectionNotEstablished
35
+ ActiveRecord::StatementInvalid
36
+ PG::UnableToSend
37
+ PG::Error
38
+ ].freeze
32
39
 
33
40
  # @!attribute [r] instances
34
41
  # @!scope class
@@ -115,15 +122,18 @@ module GoodJob # :nodoc:
115
122
  if thread_error
116
123
  GoodJob.on_thread_error.call(thread_error) if GoodJob.on_thread_error.respond_to?(:call)
117
124
  ActiveSupport::Notifications.instrument("notifier_notify_error.good_job", { error: thread_error })
125
+
126
+ connection_error = CONNECTION_ERRORS.any? do |error_string|
127
+ error_class = error_string.safe_constantize
128
+ next unless error_class
129
+
130
+ thread_error.is_a? error_class
131
+ end
118
132
  end
119
133
 
120
134
  return if shutdown?
121
135
 
122
- if thread_error.is_a?(ActiveRecord::ConnectionNotEstablished) || thread_error.is_a?(ActiveRecord::StatementInvalid)
123
- listen(delay: RECONNECT_INTERVAL)
124
- else
125
- listen
126
- end
136
+ listen(delay: connection_error ? RECONNECT_INTERVAL : 0)
127
137
  end
128
138
 
129
139
  private
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoodJob
4
+ class ProbeServer
5
+ RACK_SERVER = 'webrick'
6
+
7
+ def self.task_observer(time, output, thread_error) # rubocop:disable Lint/UnusedMethodArgument
8
+ return if thread_error.is_a? Concurrent::CancelledOperationError
9
+
10
+ GoodJob.on_thread_error.call(thread_error) if thread_error && GoodJob.on_thread_error.respond_to?(:call)
11
+ end
12
+
13
+ def initialize(port:)
14
+ @port = port
15
+ end
16
+
17
+ def start
18
+ @handler = Rack::Handler.get(RACK_SERVER)
19
+ @future = Concurrent::Future.new(args: [@handler, @port, GoodJob.logger]) do |thr_handler, thr_port, thr_logger|
20
+ thr_handler.run(self, Port: thr_port, Logger: thr_logger, AccessLog: [])
21
+ end
22
+ @future.add_observer(self.class, :task_observer)
23
+ @future.execute
24
+ end
25
+
26
+ def running?
27
+ @handler&.instance_variable_get(:@server)&.status == :Running
28
+ end
29
+
30
+ def stop
31
+ @handler&.shutdown
32
+ @future&.value # wait for Future to exit
33
+ end
34
+
35
+ def call(env)
36
+ case Rack::Request.new(env).path
37
+ when '/', '/status'
38
+ [200, {}, ["OK"]]
39
+ when '/status/started'
40
+ started = GoodJob::Scheduler.instances.any? && GoodJob::Scheduler.instances.all?(&:running?)
41
+ started ? [200, {}, ["Started"]] : [503, {}, ["Not started"]]
42
+ when '/status/connected'
43
+ connected = GoodJob::Scheduler.instances.any? && GoodJob::Scheduler.instances.all?(&:running?) &&
44
+ GoodJob::Notifier.instances.any? && GoodJob::Notifier.instances.all?(&:listening?)
45
+ connected ? [200, {}, ["Connected"]] : [503, {}, ["Not connected"]]
46
+ else
47
+ [404, {}, ["Not found"]]
48
+ end
49
+ end
50
+ end
51
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
  module GoodJob
3
3
  # GoodJob gem version.
4
- VERSION = '2.5.0'
4
+ VERSION = '2.7.0'
5
5
  end