good_job 2.6.0 → 2.7.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.
@@ -5,6 +5,25 @@ module GoodJob
5
5
 
6
6
  around_action :switch_locale
7
7
 
8
+ content_security_policy do |policy|
9
+ policy.default_src(:none) if policy.default_src(*policy.default_src).blank?
10
+ policy.connect_src(:self) if policy.connect_src(*policy.connect_src).blank?
11
+ policy.base_uri(:none) if policy.base_uri(*policy.base_uri).blank?
12
+ policy.font_src(:self) if policy.font_src(*policy.font_src).blank?
13
+ policy.img_src(:self, :data) if policy.img_src(*policy.img_src).blank?
14
+ policy.object_src(:none) if policy.object_src(*policy.object_src).blank?
15
+ policy.script_src(:self) if policy.script_src(*policy.script_src).blank?
16
+ policy.style_src(:self) if policy.style_src(*policy.style_src).blank?
17
+ policy.form_action(:self) if policy.form_action(*policy.form_action).blank?
18
+ policy.frame_ancestors(:none) if policy.frame_ancestors(*policy.frame_ancestors).blank?
19
+ end
20
+
21
+ before_action do
22
+ next if request.content_security_policy_nonce_generator
23
+
24
+ request.content_security_policy_nonce_generator = ->(_request) { SecureRandom.base64(16) }
25
+ end
26
+
8
27
  private
9
28
 
10
29
  def switch_locale(&action)
@@ -2,6 +2,7 @@
2
2
  module GoodJob
3
3
  class BaseFilter
4
4
  DEFAULT_LIMIT = 25
5
+ EMPTY = '[none]'
5
6
 
6
7
  attr_accessor :params, :base_query
7
8
 
@@ -31,7 +32,7 @@ module GoodJob
31
32
 
32
33
  def queues
33
34
  base_query.group(:queue_name).count
34
- .sort_by { |name, _count| name.to_s }
35
+ .sort_by { |name, _count| name.to_s || EMPTY }
35
36
  .to_h
36
37
  end
37
38
 
@@ -39,58 +40,18 @@ module GoodJob
39
40
  raise NotImplementedError
40
41
  end
41
42
 
42
- def to_params(override)
43
+ def to_params(override = {})
43
44
  {
44
45
  job_class: params[:job_class],
45
46
  limit: params[:limit],
46
47
  queue_name: params[:queue_name],
48
+ query: params[:query],
47
49
  state: params[:state],
48
- }.merge(override).delete_if { |_, v| v.nil? }
50
+ }.merge(override).delete_if { |_, v| v.blank? }
49
51
  end
50
52
 
51
- def chart_data
52
- count_query = Arel.sql(GoodJob::Execution.pg_or_jdbc_query(<<~SQL.squish))
53
- SELECT *
54
- FROM generate_series(
55
- date_trunc('hour', $1::timestamp),
56
- date_trunc('hour', $2::timestamp),
57
- '1 hour'
58
- ) timestamp
59
- LEFT JOIN (
60
- SELECT
61
- date_trunc('hour', scheduled_at) AS scheduled_at,
62
- queue_name,
63
- count(*) AS count
64
- FROM (
65
- #{filtered_query.except(:select).select('queue_name', 'COALESCE(good_jobs.scheduled_at, good_jobs.created_at)::timestamp AS scheduled_at').to_sql}
66
- ) sources
67
- GROUP BY date_trunc('hour', scheduled_at), queue_name
68
- ) sources ON sources.scheduled_at = timestamp
69
- ORDER BY timestamp ASC
70
- SQL
71
-
72
- current_time = Time.current
73
- binds = [[nil, current_time - 1.day], [nil, current_time]]
74
- executions_data = GoodJob::Execution.connection.exec_query(count_query, "GoodJob Dashboard Chart", binds)
75
-
76
- queue_names = executions_data.map { |d| d['queue_name'] }.uniq
77
- labels = []
78
- queues_data = executions_data.to_a.group_by { |d| d['timestamp'] }.each_with_object({}) do |(timestamp, values), hash|
79
- labels << timestamp.in_time_zone.strftime('%H:%M %z')
80
- queue_names.each do |queue_name|
81
- (hash[queue_name] ||= []) << values.find { |d| d['queue_name'] == queue_name }&.[]('count')
82
- end
83
- end
84
-
85
- {
86
- labels: labels,
87
- series: queues_data.map do |queue, data|
88
- {
89
- name: queue,
90
- data: data,
91
- }
92
- end,
93
- }
53
+ def filtered_query
54
+ raise NotImplementedError
94
55
  end
95
56
 
96
57
  private
@@ -98,9 +59,5 @@ module GoodJob
98
59
  def default_base_query
99
60
  raise NotImplementedError
100
61
  end
101
-
102
- def filtered_query
103
- raise NotImplementedError
104
- end
105
62
  end
106
63
  end
@@ -10,16 +10,11 @@ module GoodJob
10
10
  }
11
11
  end
12
12
 
13
- private
14
-
15
- def default_base_query
16
- GoodJob::Execution.all
17
- end
18
-
19
13
  def filtered_query
20
14
  query = base_query
21
- query = query.job_class(params[:job_class]) if params[:job_class]
22
- query = query.where(queue_name: params[:queue_name]) if params[:queue_name]
15
+ query = query.job_class(params[:job_class]) if params[:job_class].present?
16
+ query = query.where(queue_name: params[:queue_name]) if params[:queue_name].present?
17
+ query = query.search_text(params[:query]) if params[:query].present?
23
18
 
24
19
  if params[:state]
25
20
  case params[:state]
@@ -36,5 +31,11 @@ module GoodJob
36
31
 
37
32
  query
38
33
  end
34
+
35
+ private
36
+
37
+ def default_base_query
38
+ GoodJob::Execution.all
39
+ end
39
40
  end
40
41
  end
@@ -12,18 +12,13 @@ module GoodJob
12
12
  }
13
13
  end
14
14
 
15
- private
16
-
17
- def default_base_query
18
- GoodJob::ActiveJobJob.all
19
- end
20
-
21
15
  def filtered_query
22
16
  query = base_query.includes(:executions)
23
17
  .joins_advisory_locks.select('good_jobs.*', 'pg_locks.locktype AS locktype')
24
18
 
25
- query = query.job_class(params[:job_class]) if params[:job_class]
26
- query = query.where(queue_name: params[:queue_name]) if params[:queue_name]
19
+ query = query.job_class(params[:job_class]) if params[:job_class].present?
20
+ query = query.where(queue_name: params[:queue_name]) if params[:queue_name].present?
21
+ query = query.search_text(params[:query]) if params[:query].present?
27
22
 
28
23
  if params[:state]
29
24
  case params[:state]
@@ -44,5 +39,11 @@ module GoodJob
44
39
 
45
40
  query
46
41
  end
42
+
43
+ private
44
+
45
+ def default_base_query
46
+ GoodJob::ActiveJobJob.all
47
+ end
47
48
  end
48
49
  end
@@ -1,5 +1,5 @@
1
1
  <div class="card my-3 p-6">
2
- <%= render 'good_job/shared/chart', chart_data: @filter.chart_data %>
2
+ <%= render 'good_job/shared/chart', chart_data: GoodJob::ScheduledByQueueChart.new(@filter).data %>
3
3
  </div>
4
4
 
5
5
  <%= render 'good_job/shared/filter', filter: @filter %>
@@ -22,7 +22,7 @@
22
22
  </thead>
23
23
  <tbody>
24
24
  <% jobs.each do |job| %>
25
- <tr id="<%= dom_id(job) %>">
25
+ <tr class="<%= dom_class(job) %>" id="<%= dom_id(job) %>">
26
26
  <td>
27
27
  <%= link_to job_path(job.id) do %>
28
28
  <code><%= job.id %></code>
@@ -1,5 +1,5 @@
1
1
  <div class="card my-3 p-6">
2
- <%= render 'good_job/shared/chart', chart_data: @filter.chart_data %>
2
+ <%= render 'good_job/shared/chart', chart_data: GoodJob::ScheduledByQueueChart.new(@filter).data %>
3
3
  </div>
4
4
 
5
5
  <%= render 'good_job/shared/filter', filter: @filter %>
@@ -1,52 +1,25 @@
1
- <div id="chart"></div>
1
+ <div class="chart-wrapper">
2
+ <canvas id="chart"></canvas>
3
+ </div>
2
4
 
3
5
  <%= javascript_tag nonce: true do %>
4
- new Chartist.Line('#chart', <%== chart_data.to_json %>, {
5
- height: '300px',
6
- fullWidth: true,
7
- chartPadding: {
8
- right: 40,
9
- top: 20,
10
- bottom: 20
6
+ const chartData = <%== chart_data.to_json %>;
7
+
8
+ const ctx = document.getElementById('chart').getContext('2d');
9
+ const chart = new Chart(ctx, {
10
+ type: 'line',
11
+ data: {
12
+ labels: chartData.labels,
13
+ datasets: chartData.datasets
11
14
  },
12
- axisX: {
13
- labelInterpolationFnc: function(value, index) {
14
- return index % 3 === 0 ? value : null;
15
+ options: {
16
+ responsive: true,
17
+ maintainAspectRatio: false,
18
+ scales: {
19
+ y: {
20
+ beginAtZero: true
21
+ }
15
22
  }
16
- },
17
- axisY: {
18
- low: 0,
19
- onlyInteger: true
20
23
  }
21
- })
22
-
23
- // https://www.smashingmagazine.com/2014/12/chartist-js-open-source-library-responsive-charts/
24
- const chartEl = document.getElementById('chart');
25
- const tooltipEl = document.createElement('div')
26
-
27
- tooltipEl.classList.add('tooltip', 'tooltip-hidden');
28
- chartEl.appendChild(tooltipEl);
29
-
30
- document.body.addEventListener('mouseenter', function (event) {
31
- if (!(event.target.matches && event.target.matches('.ct-point'))) return;
32
-
33
- const seriesName = event.target.closest('.ct-series').getAttribute('ct:series-name');
34
- const value = event.target.getAttribute('ct:value');
35
-
36
- tooltipEl.innerText = seriesName + ': ' + value;
37
- tooltipEl.classList.remove('tooltip-hidden');
38
- }, true);
39
-
40
- document.body.addEventListener('mouseleave', function (event) {
41
- if (!(event.target.matches && event.target.matches('.ct-point'))) return;
42
-
43
- tooltipEl.classList.add('tooltip-hidden');
44
- }, true);
45
-
46
- document.body.addEventListener('mousemove', function(event) {
47
- if (!(event.target.matches && event.target.matches('.ct-point'))) return;
48
-
49
- tooltipEl.style.left = (event.offsetX || event.originalEvent.layerX) + tooltipEl.offsetWidth + 10 + 'px';
50
- tooltipEl.style.top = (event.offsetY || event.originalEvent.layerY) + tooltipEl.offsetHeight - 20 + 'px';
51
- }, true);
24
+ });
52
25
  <% end %>
@@ -1,6 +1,6 @@
1
1
  <div class='card mb-2'>
2
2
  <div class='card-body d-flex flex-wrap'>
3
- <div class='me-4'>
3
+ <div class='mb-2 me-4'>
4
4
  <small>Filter by job class</small>
5
5
  <br>
6
6
  <% filter.job_classes.each do |name, count| %>
@@ -16,7 +16,7 @@
16
16
  <% end %>
17
17
  </div>
18
18
 
19
- <div class='me-4'>
19
+ <div class='mb-2 me-4'>
20
20
  <small>Filter by state</small>
21
21
  <br>
22
22
  <% filter.states.each do |name, count| %>
@@ -32,7 +32,7 @@
32
32
  <% end %>
33
33
  </div>
34
34
 
35
- <div>
35
+ <div class='mb-2 me-4'>
36
36
  <small>Filter by queue</small>
37
37
  <br>
38
38
  <% filter.queues.each do |name, count| %>
@@ -47,5 +47,20 @@
47
47
  <% end %>
48
48
  <% end %>
49
49
  </div>
50
+
51
+ <div class="mb-2">
52
+ <%= form_with(url: "", method: :get, local: true) do |form| %>
53
+ <% filter.to_params(query: nil).each do |key, value| %>
54
+ <%= form.hidden_field(key.to_sym, value: value) %>
55
+ <% end %>
56
+
57
+ <small><%= form.label :query, "Search" %></small>
58
+ <div class="input-group input-group-sm">
59
+ <%= form.search_field :query, value: params[:query], class: "form-control" %>
60
+ <%= form.button "Search", type: "submit", name: nil, class: "btn btn-sm btn-outline-secondary" %>
61
+ <%= link_to "Clear", filter.to_params(query: nil), class: "btn btn-sm btn-outline-secondary" %>
62
+ </div>
63
+ <% end %>
64
+ </div>
50
65
  </div>
51
66
  </div>
@@ -1,16 +1,17 @@
1
1
  <!DOCTYPE html>
2
- <html>
2
+ <html lang="en">
3
3
  <head>
4
4
  <title>Good Job Dashboard</title>
5
5
  <%= csrf_meta_tags %>
6
6
  <%= csp_meta_tag %>
7
7
 
8
8
  <%= stylesheet_link_tag bootstrap_path(format: :css, v: GoodJob::VERSION) %>
9
- <%= stylesheet_link_tag chartist_path(format: :css, v: GoodJob::VERSION) %>
10
9
  <%= stylesheet_link_tag style_path(format: :css, v: GoodJob::VERSION) %>
11
10
 
12
11
  <%= javascript_include_tag bootstrap_path(format: :js, v: GoodJob::VERSION) %>
13
- <%= javascript_include_tag chartist_path(format: :js, v: GoodJob::VERSION) %>
12
+ <%= javascript_include_tag chartjs_path(format: :js, v: GoodJob::VERSION) %>
13
+ <%= javascript_include_tag scripts_path(format: :js, v: GoodJob::VERSION) %>
14
+
14
15
  <%= javascript_include_tag rails_ujs_path(format: :js, v: GoodJob::VERSION) %>
15
16
  </head>
16
17
  <body>
@@ -20,14 +20,14 @@ GoodJob::Engine.routes.draw do
20
20
  scope controller: :assets do
21
21
  constraints(format: :css) do
22
22
  get :bootstrap, action: :bootstrap_css
23
- get :chartist, action: :chartist_css
24
23
  get :style, action: :style_css
25
24
  end
26
25
 
27
26
  constraints(format: :js) do
28
27
  get :bootstrap, action: :bootstrap_js
29
28
  get :rails_ujs, action: :rails_ujs_js
30
- get :chartist, action: :chartist_js
29
+ get :chartjs, action: :chartjs_js
30
+ get :scripts, action: :scripts_js
31
31
  end
32
32
  end
33
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
@@ -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
@@ -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]
@@ -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
@@ -13,9 +13,6 @@ module GoodJob
13
13
  # @param queue_string [String] Queues to execute jobs from
14
14
  def initialize(queue_string)
15
15
  @queue_string = queue_string
16
-
17
- @job_query = Concurrent::Delay.new { GoodJob::Execution.queue_string(queue_string) }
18
- @parsed_queues = Concurrent::Delay.new { GoodJob::Execution.queue_parser(queue_string) }
19
16
  end
20
17
 
21
18
  # A meaningful name to identify the performer in logs and for debugging.
@@ -65,11 +62,11 @@ module GoodJob
65
62
  attr_reader :queue_string
66
63
 
67
64
  def job_query
68
- @job_query.value
65
+ @_job_query ||= GoodJob::Execution.queue_string(queue_string)
69
66
  end
70
67
 
71
68
  def parsed_queues
72
- @parsed_queues.value
69
+ @_parsed_queues ||= GoodJob::Execution.queue_parser(queue_string)
73
70
  end
74
71
  end
75
72
  end
@@ -24,7 +24,7 @@ module GoodJob
24
24
 
25
25
  included do
26
26
  # Default column to be used when creating Advisory Locks
27
- class_attribute :advisory_lockable_column, instance_accessor: false, default: Concurrent::Delay.new { primary_key }
27
+ class_attribute :advisory_lockable_column, instance_accessor: false, default: nil
28
28
 
29
29
  # Default Postgres function to be used for Advisory Locks
30
30
  class_attribute :advisory_lockable_function, default: "pg_try_advisory_lock"
@@ -161,10 +161,8 @@ module GoodJob
161
161
  end
162
162
  end
163
163
 
164
- # Allow advisory_lockable_column to be a `Concurrent::Delay`
165
164
  def _advisory_lockable_column
166
- column = advisory_lockable_column
167
- column.respond_to?(:value) ? column.value : column
165
+ advisory_lockable_column || primary_key
168
166
  end
169
167
 
170
168
  def supports_cte_materialization_specifiers?
@@ -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.6.0'
4
+ VERSION = '2.7.1'
5
5
  end