good_job 4.22.0 → 4.23.0

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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +55 -0
  3. data/README.md +11 -1
  4. data/app/controllers/good_job/executions_controller.rb +11 -0
  5. data/app/frontend/good_job/style.css +5 -0
  6. data/app/helpers/good_job/application_helper.rb +5 -4
  7. data/app/models/concerns/good_job/advisory_lockable.rb +33 -17
  8. data/app/models/concerns/good_job/filtered_arguments.rb +27 -0
  9. data/app/models/good_job/batch_record.rb +0 -1
  10. data/app/models/good_job/concurrency_claim.rb +3 -1
  11. data/app/models/good_job/cron_entry.rb +9 -11
  12. data/app/models/good_job/execution.rb +2 -0
  13. data/app/models/good_job/job.rb +2 -0
  14. data/app/models/good_job/process.rb +1 -0
  15. data/app/views/good_job/batches/_jobs.erb +9 -9
  16. data/app/views/good_job/executions/show.html.erb +11 -0
  17. data/app/views/good_job/jobs/_executions.erb +2 -6
  18. data/app/views/good_job/jobs/_table.erb +10 -10
  19. data/app/views/good_job/jobs/show.html.erb +1 -1
  20. data/config/brakeman.ignore +3 -3
  21. data/config/locales/pt-BR.yml +25 -23
  22. data/config/routes.rb +3 -1
  23. data/lib/generators/good_job/templates/install/migrations/create_good_jobs.rb.erb +1 -0
  24. data/lib/generators/good_job/templates/update/migrations/01_create_good_jobs.rb.erb +1 -0
  25. data/lib/generators/good_job/templates/update/migrations/22_add_index_good_job_executions_on_created_at.rb.erb +12 -0
  26. data/lib/good_job/active_job_extensions/concurrency.rb +9 -2
  27. data/lib/good_job/adapter.rb +72 -29
  28. data/lib/good_job/capsule.rb +8 -8
  29. data/lib/good_job/capsule_tracker.rb +29 -11
  30. data/lib/good_job/configuration.rb +10 -1
  31. data/lib/good_job/cron_manager.rb +5 -6
  32. data/lib/good_job/engine.rb +4 -0
  33. data/lib/good_job/instance_registry.rb +45 -0
  34. data/lib/good_job/job_performer.rb +2 -1
  35. data/lib/good_job/log_subscriber.rb +9 -0
  36. data/lib/good_job/notifier.rb +5 -6
  37. data/lib/good_job/poller.rb +4 -6
  38. data/lib/good_job/probe_server/simple_handler.rb +3 -0
  39. data/lib/good_job/probe_server/webrick_handler.rb +2 -0
  40. data/lib/good_job/probe_server.rb +2 -0
  41. data/lib/good_job/ractors.rb +99 -0
  42. data/lib/good_job/scheduler.rb +3 -6
  43. data/lib/good_job/shared_executor.rb +4 -6
  44. data/lib/good_job/systemd_service.rb +1 -0
  45. data/lib/good_job/version.rb +1 -1
  46. data/lib/good_job.rb +81 -11
  47. metadata +7 -1
@@ -37,17 +37,17 @@ pt-BR:
37
37
  no_batches_found: Nenhum lote encontrado.
38
38
  cleaner:
39
39
  index:
40
- all: All
41
- class: Class
42
- exception: Exception
43
- grouped_by_class: Discards grouped by Class
44
- grouped_by_exception: Discards grouped by Exception
45
- last_1_hour: Last 1 hour
46
- last_24_hours: Last 24 hours
47
- last_3_days: Last 3 days
48
- last_3_hours: Last 3 hours
49
- last_7_days: Last 7 days
50
- title: Discard Cleaner
40
+ all: Tudo
41
+ class: Classe
42
+ exception: Exceção
43
+ grouped_by_class: Descartes agrupados por Classe
44
+ grouped_by_exception: Descartes agrupados por Exceção
45
+ last_1_hour: Última hora
46
+ last_24_hours: Últimas 24 horas
47
+ last_3_days: Últimos 3 dias
48
+ last_3_hours: Últimas 3 horas
49
+ last_7_days: Últimos 7 dias
50
+ title: Limpeza de Descartes
51
51
  total: Total
52
52
  concurrency_claims:
53
53
  states:
@@ -142,12 +142,12 @@ pt-BR:
142
142
  reschedule: Reagendar tarefa
143
143
  retry: Tentar tarefa novamente
144
144
  destroy:
145
- notice: A tarefa foi excluida.
145
+ notice: A tarefa foi excluída.
146
146
  discard:
147
147
  notice: A tarefa foi descartada.
148
148
  executions:
149
- application_trace: Application Trace
150
- full_trace: Full Trace
149
+ application_trace: Trace da aplicação
150
+ full_trace: Trace completo
151
151
  in_queue: na fila
152
152
  recovery: recuperação
153
153
  runtime: tempo de execução
@@ -175,7 +175,7 @@ pt-BR:
175
175
  destroy_all: Excluir tudo
176
176
  discard_all: Descartar tudo
177
177
  reschedule_all: Reagendar tudo
178
- retry_all: Tentar novamente tudo
178
+ retry_all: Tentar tudo novamente
179
179
  title: Ações
180
180
  no_jobs_found: Nenhuma tarefa encontrada.
181
181
  toggle_actions: Alternar Ações
@@ -204,7 +204,7 @@ pt-BR:
204
204
  job:
205
205
  arguments: Argumentos
206
206
  attempts: Tentativas
207
- concurrency_claims: Reivindicações de concorrência
207
+ concurrency_claims: Reservas de concorrência
208
208
  labels: Rótulos
209
209
  priority: Prioridade
210
210
  queue: Fila
@@ -230,7 +230,7 @@ pt-BR:
230
230
  confirm_pause: Tem certeza que deseja pausar %{value}?
231
231
  confirm_unpause: Tem certeza que deseja retomar %{value}?
232
232
  disabled: O recurso experimental de Pausas do GoodJob está desativado por padrão porque pode degradar o desempenho. Configure para ativar
233
- job_class: Classe do trabalho
233
+ job_class: Classe da tarefa
234
234
  label: Rótulo
235
235
  pause: Pausar
236
236
  queue: Fila
@@ -245,8 +245,8 @@ pt-BR:
245
245
  chart_title_total: Latência total média em segundos
246
246
  execution_latency: Latência de execução
247
247
  executions: Execuções
248
- job_class: Classe do trabalho
249
- jobs: Trabalhos
248
+ job_class: Classe da tarefa
249
+ jobs: Tarefas
250
250
  no_executions: Nenhuma execução neste intervalo de tempo.
251
251
  queue_latency: Latência na fila
252
252
  queue_name: Nome da fila
@@ -267,7 +267,9 @@ pt-BR:
267
267
  days: "%{count}d"
268
268
  hours: "%{count}h"
269
269
  minutes: "%{count}m"
270
- months: "~%{count} mês"
270
+ months:
271
+ one: "~%{count} mês"
272
+ other: "~%{count} meses"
271
273
  seconds: "%{count}s"
272
274
  years: "~%{count}a"
273
275
  chart_bucket_size: 'Granularidade temporal do gráfico: %{size}'
@@ -317,8 +319,8 @@ pt-BR:
317
319
  search: Pesquisar
318
320
  navbar:
319
321
  batches: Lotes
320
- cleaner: Discard Cleaner
321
- cron_schedules: Agendamentos
322
+ cleaner: Limpeza de Descartes
323
+ cron_schedules: Tarefas Programadas
322
324
  jobs: Tarefas
323
325
  live_poll: Acompanhamento ao Vivo
324
326
  name: "GoodJob 👍"
@@ -332,7 +334,7 @@ pt-BR:
332
334
  theme: Tema
333
335
  pending_migrations: O GoodJob tem migrações pendentes no banco de dados.
334
336
  secondary_navbar:
335
- inspiration: Lembre-se, você também está fazendo uma Boa Tarefa!
337
+ inspiration: Lembre-se, você também está fazendo um Bom Trabalho!
336
338
  last_updated: Última atualização
337
339
  status:
338
340
  discarded: Descartado
data/config/routes.rb CHANGED
@@ -5,7 +5,7 @@ GoodJob::Engine.routes.draw do
5
5
 
6
6
  resources :jobs, only: %i[index show destroy] do
7
7
  collection do
8
- get :mass_update, to: redirect(path: 'jobs')
8
+ get :mass_update, to: ->(_env) { raise ActionController::RoutingError, 'Not Found' }
9
9
  put :mass_update
10
10
  end
11
11
 
@@ -15,6 +15,8 @@ GoodJob::Engine.routes.draw do
15
15
  put :reschedule
16
16
  put :retry
17
17
  end
18
+
19
+ resources :executions, only: %i[show]
18
20
  end
19
21
  get 'jobs/metrics/primary_nav', to: 'metrics#primary_nav', as: :metrics_primary_nav
20
22
  get 'jobs/metrics/job_status', to: 'metrics#job_status', as: :metrics_job_status
@@ -114,6 +114,7 @@ class CreateGoodJobs < ActiveRecord::Migration<%= migration_version %>
114
114
 
115
115
  add_index :good_job_executions, [:active_job_id, :created_at], name: :index_good_job_executions_on_active_job_id_and_created_at
116
116
  add_index :good_job_executions, :scheduled_at, name: :index_good_job_executions_on_scheduled_at
117
+ add_index :good_job_executions, :created_at, name: :index_good_job_executions_on_created_at
117
118
  add_index :good_jobs, [:priority, :scheduled_at, :id],
118
119
  where: "finished_at IS NULL", name: "index_good_jobs_on_priority_scheduled_at_unfinished"
119
120
  add_index :good_jobs, [:queue_name, :scheduled_at, :id],
@@ -97,6 +97,7 @@ class CreateGoodJobs < ActiveRecord::Migration<%= migration_version %>
97
97
 
98
98
  add_index :good_job_executions, [:active_job_id, :created_at], name: :index_good_job_executions_on_active_job_id_and_created_at
99
99
  add_index :good_job_executions, :scheduled_at, name: :index_good_job_executions_on_scheduled_at
100
+ add_index :good_job_executions, :created_at, name: :index_good_job_executions_on_created_at
100
101
  add_index :good_jobs, :locked_by_id,
101
102
  where: "locked_by_id IS NOT NULL", name: "index_good_jobs_on_locked_by_id"
102
103
  add_index :good_jobs, [:priority, :scheduled_at, :id],
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddIndexGoodJobExecutionsOnCreatedAt < ActiveRecord::Migration<%= migration_version %>
4
+ disable_ddl_transaction!
5
+
6
+ def change
7
+ add_index :good_job_executions, :created_at,
8
+ name: :index_good_job_executions_on_created_at,
9
+ algorithm: :concurrently,
10
+ if_not_exists: true
11
+ end
12
+ end
@@ -6,6 +6,8 @@ module GoodJob
6
6
  extend ActiveSupport::Concern
7
7
 
8
8
  VALID_TYPES = [String, Symbol, Numeric, Date, Time, TrueClass, FalseClass, NilClass].freeze
9
+ # The lock and claim key for a rule without a label or key, which counts all jobs.
10
+ ALL_KEY = "all"
9
11
 
10
12
  class ConcurrencyExceededError < StandardError
11
13
  def backtrace
@@ -52,6 +54,9 @@ module GoodJob
52
54
 
53
55
  def evaluate(job, stage)
54
56
  resolved_label = resolve_label(job)
57
+ # A rule with a label that resolves to blank does not apply
58
+ return nil if @label.present? && resolved_label.blank?
59
+
55
60
  resolved_key = resolve_key(job, resolved_label)
56
61
  return nil if resolved_key.blank? && resolved_label.blank?
57
62
 
@@ -77,8 +82,10 @@ module GoodJob
77
82
  end
78
83
 
79
84
  def resolve_key(job, label)
80
- if label.present? || key.blank?
85
+ if label.present?
81
86
  "label:#{label}"
87
+ elsif key.blank?
88
+ ALL_KEY
82
89
  else
83
90
  key_value = @key.respond_to?(:call) ? job.instance_exec(&@key) : @key
84
91
  raise TypeError, "Concurrency key must be a String; was a #{key_value.class}" if key_value.present? && VALID_TYPES.none? { |type| key_value.is_a?(type) }
@@ -117,7 +124,7 @@ module GoodJob
117
124
  elsif key_explicit? && key.present?
118
125
  "key:#{key}"
119
126
  else
120
- "all"
127
+ ALL_KEY
121
128
  end
122
129
  end
123
130
 
@@ -5,11 +5,7 @@ module GoodJob
5
5
  # ActiveJob Adapter.
6
6
  #
7
7
  class Adapter
8
- # @!attribute [r] instances
9
- # @!scope class
10
- # List of all instantiated Adapters in the current process.
11
- # @return [Array<GoodJob::Adapter>, nil]
12
- cattr_reader :instances, default: Concurrent::Array.new, instance_reader: false
8
+ include InstanceRegistry
13
9
 
14
10
  # @param execution_mode [Symbol, nil] specifies how and where jobs should be executed. You can also set this with the environment variable +GOOD_JOB_EXECUTION_MODE+.
15
11
  #
@@ -25,13 +21,15 @@ module GoodJob
25
21
  # -+test+: +:inline+
26
22
  # - +production+ and all other environments: +:external+
27
23
  #
28
- def initialize(execution_mode: nil, _capsule: GoodJob.capsule) # rubocop:disable Lint/UnderscorePrefixedVariableName
24
+ # @param _capsule [GoodJob::Capsule, nil] Capsule to use instead of {GoodJob.capsule}. An adapter with
25
+ # its own capsule cannot be made Ractor-shareable.
26
+ def initialize(execution_mode: nil, _capsule: nil) # rubocop:disable Lint/UnderscorePrefixedVariableName
29
27
  @_execution_mode_override = execution_mode
30
28
  GoodJob::Configuration.validate_execution_mode(@_execution_mode_override) if @_execution_mode_override
31
- @capsule = _capsule
29
+ @_capsule = _capsule
32
30
 
33
31
  start_async if GoodJob.async_ready?
34
- self.class.instances << self
32
+ register_instance
35
33
  end
36
34
 
37
35
  # Enqueues the ActiveJob job to be performed.
@@ -75,16 +73,19 @@ module GoodJob
75
73
  lock_id = nil
76
74
 
77
75
  if execute_inline?
78
- @capsule.tracker.register
76
+ lock_id = register_tracker
79
77
  tracker_registered = true
80
- lock_id = @capsule.tracker.id_for_lock
81
78
  end
82
79
 
83
80
  begin
84
81
  GoodJob::Job.transaction(requires_new: true, joinable: false) do
85
- column_names = GoodJob::Job.column_names
86
- job_attributes = jobs.map { |job| job.attributes.slice(*column_names) }
87
- results = GoodJob::Job.insert_all(job_attributes, returning: %w[id active_job_id]) # rubocop:disable Rails/SkipsModelValidations
82
+ results = if GoodJob::Ractors.main?
83
+ column_names = GoodJob::Job.column_names
84
+ job_attributes = jobs.map { |job| job.attributes.slice(*column_names) }
85
+ GoodJob::Job.insert_all(job_attributes, returning: %w[id active_job_id]) # rubocop:disable Rails/SkipsModelValidations
86
+ else
87
+ insert_individually(jobs)
88
+ end
88
89
 
89
90
  job_id_to_provider_job_id = results.to_h { |result| [result['active_job_id'], result['id']] }
90
91
  active_jobs.each do |active_job|
@@ -120,16 +121,16 @@ module GoodJob
120
121
  end
121
122
  ensure
122
123
  inline_jobs.each(&:advisory_unlock)
123
- @capsule.tracker.unregister if tracker_registered
124
+ unregister_tracker if tracker_registered
124
125
  tracker_registered = false
125
126
  end
126
127
  elsif tracker_registered
127
- @capsule.tracker.unregister
128
+ unregister_tracker
128
129
  tracker_registered = false
129
130
  end
130
131
  rescue StandardError
131
132
  if tracker_registered
132
- @capsule.tracker.unregister
133
+ unregister_tracker
133
134
  tracker_registered = false
134
135
  end
135
136
  raise
@@ -147,7 +148,7 @@ module GoodJob
147
148
  state = { queue_name: queue_name, count: jobs_by_queue_and_scheduled_at.size }
148
149
  state[:scheduled_at] = scheduled_at if scheduled_at
149
150
 
150
- executed_locally = execute_async? && @capsule&.create_thread(state)
151
+ executed_locally = execute_async? && create_capsule_thread(state)
151
152
  unless executed_locally
152
153
  state[:count] = job_id_to_active_jobs.values_at(*jobs_by_queue_and_scheduled_at.map(&:active_job_id)).count { |active_job| send_notify?(active_job) }
153
154
  Notifier.notify(state) unless state[:count].zero?
@@ -183,8 +184,7 @@ module GoodJob
183
184
  )
184
185
  elsif will_execute_inline
185
186
  lock_strategy = GoodJob::Job.effective_lock_strategy
186
- @capsule.tracker.register
187
- lock_id = @capsule.tracker.id_for_lock
187
+ lock_id = register_tracker
188
188
  tracker_registered = true
189
189
  begin
190
190
  job = case lock_strategy
@@ -198,12 +198,12 @@ module GoodJob
198
198
  InlineBuffer.perform_now_or_defer do
199
199
  perform_inline(job, notify: send_notify?(active_job), already_claimed: lock_strategy != :advisory, advisory_unlock: lock_strategy != :skiplocked)
200
200
  ensure
201
- @capsule.tracker.unregister if tracker_registered
201
+ unregister_tracker if tracker_registered
202
202
  tracker_registered = false
203
203
  end
204
204
  rescue StandardError
205
205
  if tracker_registered
206
- @capsule.tracker.unregister
206
+ unregister_tracker
207
207
  tracker_registered = false
208
208
  end
209
209
  raise
@@ -215,7 +215,7 @@ module GoodJob
215
215
  )
216
216
 
217
217
  if job
218
- executed_locally = execute_async? && @capsule&.create_thread(job.job_state)
218
+ executed_locally = execute_async? && create_capsule_thread(job.job_state)
219
219
  Notifier.notify(job.job_state) if !executed_locally && send_notify?(active_job)
220
220
  end
221
221
  end
@@ -246,8 +246,7 @@ module GoodJob
246
246
  # * A positive number will wait that many seconds before stopping any remaining active threads.
247
247
  # @return [void]
248
248
  def shutdown(timeout: NONE)
249
- @capsule&.shutdown(timeout: timeout)
250
- @_async_started = false
249
+ capsule.shutdown(timeout: timeout)
251
250
  end
252
251
 
253
252
  # This adapter's execution mode
@@ -282,19 +281,63 @@ module GoodJob
282
281
  def start_async
283
282
  return unless execute_async?
284
283
 
285
- @capsule.start
286
- @capsule.lower_thread_priority = true if GoodJob.configuration.lower_thread_priority.in?([true, nil])
287
- @_async_started = true
284
+ capsule.start
285
+ capsule.lower_thread_priority = true if GoodJob.configuration.lower_thread_priority.in?([true, nil])
288
286
  end
289
287
 
290
288
  # Whether the async executors are running
291
289
  # @return [Boolean]
292
290
  def async_started?
293
- @_async_started
291
+ GoodJob::Ractors.on_main(self) { capsule.running? }
292
+ end
293
+
294
+ # Adapters are retained until Rails has initialized so that async adapters can be started.
295
+ # @return [Boolean]
296
+ def prunable?
297
+ GoodJob.async_ready?
294
298
  end
295
299
 
296
300
  private
297
301
 
302
+ # The capsule is resolved on each use (rather than stored) so the adapter
303
+ # remains Ractor-shareable. Only call from the main Ractor.
304
+ # @return [GoodJob::Capsule]
305
+ def capsule
306
+ @_capsule || GoodJob.capsule
307
+ end
308
+
309
+ # The capsule is not Ractor-shareable, so it is always used on the main Ractor.
310
+ def create_capsule_thread(state)
311
+ job_state = GoodJob::Ractors.main? ? state : GoodJob::Ractors.make_shareable(state.dup)
312
+ GoodJob::Ractors.on_main(self) { capsule&.create_thread(job_state) }
313
+ end
314
+
315
+ def register_tracker
316
+ GoodJob::Ractors.on_main(self) do
317
+ tracker = capsule.tracker
318
+ tracker.register
319
+ tracker.id_for_lock
320
+ end
321
+ end
322
+
323
+ def unregister_tracker
324
+ GoodJob::Ractors.on_main(self) { capsule.tracker.unregister }
325
+ end
326
+
327
+ def tracker_id_for_lock
328
+ GoodJob::Ractors.on_main(self) { capsule.tracker.id_for_lock }
329
+ end
330
+
331
+ # Active Record does not support +insert_all+ outside of the main Ractor.
332
+ def insert_individually(jobs)
333
+ jobs.filter_map do |job|
334
+ GoodJob::Job.transaction(requires_new: true) { job.save!(validate: false) }
335
+ { 'id' => job.id, 'active_job_id' => job.active_job_id }
336
+ rescue ActiveRecord::RecordNotUnique
337
+ nil
338
+ end
339
+ end
340
+
298
341
  def send_notify?(active_job)
299
342
  return false unless GoodJob.configuration.enable_listen_notify
300
343
  return true unless active_job.respond_to?(:good_job_notify)
@@ -309,7 +352,7 @@ module GoodJob
309
352
  retried_job = nil
310
353
 
311
354
  loop do
312
- result = job.perform(lock_id: @capsule.tracker.id_for_lock, already_claimed: already_claimed)
355
+ result = job.perform(lock_id: tracker_id_for_lock, already_claimed: already_claimed)
313
356
  already_claimed = false # only skip lock-write on first iteration
314
357
  retried_job = result.retried_job
315
358
  break if retried_job.nil? || retried_job.scheduled_at.nil? || retried_job.scheduled_at > Time.current
@@ -5,11 +5,7 @@ module GoodJob
5
5
  # a {GoodJob::Scheduler}, {GoodJob::Poller}, {GoodJob::Notifier}, and {GoodJob::CronManager}.
6
6
  # GoodJob creates a default capsule on initialization.
7
7
  class Capsule
8
- # @!attribute [r] instances
9
- # @!scope class
10
- # List of all instantiated Capsules in the current process.
11
- # @return [Array<GoodJob::Capsule>, nil]
12
- cattr_reader :instances, default: Concurrent::Array.new, instance_reader: false
8
+ include InstanceRegistry
13
9
 
14
10
  delegate :register, :renew, :unregister, :id_for_lock, to: :@tracker, prefix: :_tracker
15
11
 
@@ -26,7 +22,7 @@ module GoodJob
26
22
  @tracker = GoodJob::CapsuleTracker.new(executor: @shared_executor)
27
23
  @lower_thread_priority = nil
28
24
 
29
- self.class.instances << self
25
+ register_instance
30
26
  end
31
27
 
32
28
  # Start the capsule once. After a shutdown, {#restart} must be used to start again.
@@ -37,6 +33,8 @@ module GoodJob
37
33
  @mutex.synchronize do
38
34
  return unless startable?(force: force)
39
35
 
36
+ register_instance
37
+
40
38
  @notifier = GoodJob::Notifier.new(enable_listening: configuration.enable_listen_notify, capsule: self, executor: @shared_executor)
41
39
  @poller = GoodJob::Poller.new(poll_interval: configuration.poll_interval)
42
40
  @multi_scheduler = GoodJob::MultiScheduler.from_configuration(configuration, capsule: self, warm_cache_on_initialize: true).tap do |multischeduler|
@@ -116,12 +114,14 @@ module GoodJob
116
114
  @multi_scheduler&.lower_thread_priority = value
117
115
  end
118
116
 
119
- private
120
-
117
+ # Configuration for this capsule, falling back to the global configuration.
118
+ # @return [GoodJob::Configuration]
121
119
  def configuration
122
120
  @configuration || GoodJob.configuration
123
121
  end
124
122
 
123
+ private
124
+
125
125
  def startable?(force: false)
126
126
  !@started_at && (@startable || force)
127
127
  end
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "concurrent/errors"
4
+ require "concurrent/scheduled_task"
5
+
3
6
  module GoodJob # :nodoc:
4
7
  # CapsuleTracker save a record in the database and periodically refreshes it. The intention is to
5
8
  # create a heartbeat that can be used to determine whether a capsule/process is still active
@@ -16,11 +19,7 @@ module GoodJob # :nodoc:
16
19
  # @return [Integer]
17
20
  attr_reader :advisory_locks
18
21
 
19
- # @!attribute [r] instances
20
- # @!scope class
21
- # List of all instantiated CapsuleTrackers in the current process.
22
- # @return [Array<GoodJob::CapsuleTracker>, nil]
23
- cattr_reader :instances, default: Concurrent::Array.new, instance_reader: false
22
+ include InstanceRegistry
24
23
 
25
24
  # @param executor [Concurrent::AbstractExecutorService] The executor to use for refreshing the process record.
26
25
  def initialize(executor: Concurrent.global_io_executor)
@@ -32,10 +31,9 @@ module GoodJob # :nodoc:
32
31
  @record_id = SecureRandom.uuid
33
32
  @record = nil
34
33
  @refresh_task = nil
34
+ @pid = ::Process.pid
35
35
 
36
- ActiveSupport::ForkTracker.after_fork { reset }
37
-
38
- self.class.instances << self
36
+ register_instance
39
37
  end
40
38
 
41
39
  # The UUID to use for locking. May be nil if the process is not registered or is unusable/expired.
@@ -57,10 +55,18 @@ module GoodJob # :nodoc:
57
55
  value
58
56
  end
59
57
 
58
+ # Whether the tracker has no active registrations and can be removed from {.instances}.
59
+ # It is re-added to {.instances} when it is registered again.
60
+ # @return [Boolean]
61
+ def prunable?
62
+ @locks.zero?
63
+ end
64
+
60
65
  # The expected UUID of the process for use in inspection.
61
66
  # Use {#id_for_lock} if using this as a lock key.
62
67
  # @return [String]
63
68
  def process_id
69
+ reset if forked?
64
70
  @record_id
65
71
  end
66
72
 
@@ -75,6 +81,7 @@ module GoodJob # :nodoc:
75
81
  # where with_connection always returns a newly borrowed connection.
76
82
  GoodJob::Process.lease_connection if with_advisory_lock && advisory_lock_connection.nil?
77
83
 
84
+ register_instance
78
85
  synchronize(with_connection: with_advisory_lock) do
79
86
  if with_advisory_lock && !advisory_locked?
80
87
  if @record
@@ -112,9 +119,10 @@ module GoodJob # :nodoc:
112
119
  if with_advisory_lock && @advisory_locked
113
120
  active = advisory_locked?
114
121
  if !active || matches_advisory_locked_connection?(advisory_lock_connection)
115
- @record.advisory_unlock(connection: advisory_lock_connection) if active
116
- # Remaining jobs need heartbeat liveness once the advisory lock is gone.
122
+ # Move remaining jobs to heartbeat liveness before releasing the lock; otherwise
123
+ # another process's cleanup can see an unlocked advisory record and release them.
117
124
  @record&.update(lock_type: nil) if @locks > 1
125
+ @record.advisory_unlock(connection: advisory_lock_connection) if active
118
126
  @advisory_locked = false
119
127
  @advisory_locked_connection = nil
120
128
  end
@@ -213,7 +221,14 @@ module GoodJob # :nodoc:
213
221
  synchronize { ns_reset }
214
222
  end
215
223
 
224
+ # Reset lazily after a fork (rather than with an ActiveSupport::ForkTracker callback)
225
+ # so that the tracker is not retained after it is no longer used.
226
+ def forked?
227
+ @pid != ::Process.pid
228
+ end
229
+
216
230
  def ns_reset
231
+ @pid = ::Process.pid
217
232
  @record_id = SecureRandom.uuid
218
233
  @record = nil
219
234
  @advisory_locked = false
@@ -235,7 +250,10 @@ module GoodJob # :nodoc:
235
250
  elsif @mutex.owned?
236
251
  yield
237
252
  else
238
- @mutex.synchronize(&block)
253
+ @mutex.synchronize do
254
+ ns_reset if forked?
255
+ yield
256
+ end
239
257
  end
240
258
  end
241
259
  end
@@ -528,6 +528,15 @@ module GoodJob
528
528
  (options[:dequeue_query_sort] || rails_config[:dequeue_query_sort] || :created_at).to_sym
529
529
  end
530
530
 
531
+ # Resolves lazily memoized values so the configuration can be frozen.
532
+ # @return [self]
533
+ def prepare_for_sharing
534
+ in_webserver?
535
+ validator
536
+ @_rails_config = rails_config
537
+ self
538
+ end
539
+
531
540
  private
532
541
 
533
542
  # Reads a configuration value from one or more deprecated sources, emitting a
@@ -586,7 +595,7 @@ module GoodJob
586
595
  end
587
596
 
588
597
  def rails_config
589
- Rails.application.config.good_job
598
+ @_rails_config || Rails.application.config.good_job
590
599
  end
591
600
  end
592
601
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "concurrent/errors"
4
+ require "concurrent/future"
3
5
  require "concurrent/hash"
4
6
  require "concurrent/scheduled_task"
5
7
  require "fugit"
@@ -9,11 +11,7 @@ module GoodJob # :nodoc:
9
11
  # CronManagers enqueue jobs on a repeating schedule.
10
12
  #
11
13
  class CronManager
12
- # @!attribute [r] instances
13
- # @!scope class
14
- # List of all instantiated CronManagers in the current process.
15
- # @return [Array<GoodJob::CronManager>, nil]
16
- cattr_reader :instances, default: Concurrent::Array.new, instance_reader: false
14
+ include InstanceRegistry
17
15
 
18
16
  # Task observer for cron task
19
17
  # @param time [Time]
@@ -41,11 +39,12 @@ module GoodJob # :nodoc:
41
39
  @graceful_restart_period = graceful_restart_period
42
40
 
43
41
  start if start_on_initialize
44
- self.class.instances << self
42
+ register_instance
45
43
  end
46
44
 
47
45
  # Schedule tasks that will enqueue jobs based on their schedule
48
46
  def start
47
+ register_instance
49
48
  ActiveSupport::Notifications.instrument("cron_manager_start.good_job", cron_entries: cron_entries) do
50
49
  @running = true
51
50
  graceful_restart = graceful_restart?
@@ -10,6 +10,10 @@ module GoodJob
10
10
  config.good_job = ActiveSupport::OrderedOptions.new
11
11
  config.good_job.cron = {}
12
12
 
13
+ initializer "good_job.ractors" do |app|
14
+ app.singleton_class.prepend(GoodJob::Ractors::ApplicationExtension) if GoodJob::Ractors.enabled? && app.respond_to?(:ractorize!)
15
+ end
16
+
13
17
  initializer "good_job.logger" do |_app|
14
18
  ActiveSupport.on_load(:good_job) do
15
19
  self.logger = ::Rails.logger if GoodJob.logger == GoodJob::DEFAULT_LOGGER
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent/array"
4
+
5
+ module GoodJob # :nodoc:
6
+ # Tracks instances of a class in a class-level +instances+ registry so they can be
7
+ # shut down or restarted together (e.g. by {GoodJob.shutdown} and {GoodJob.restart}).
8
+ # Instances remain in the registry after they are shut down so that they can be
9
+ # restarted; use +prune+ to remove them so they can be garbage collected.
10
+ module InstanceRegistry
11
+ extend ActiveSupport::Concern
12
+
13
+ included do
14
+ # @!attribute [r] instances
15
+ # @!scope class
16
+ # List of all registered instances in the current process.
17
+ # @return [Array]
18
+ cattr_reader :instances, default: Concurrent::Array.new, instance_reader: false
19
+ end
20
+
21
+ class_methods do
22
+ # Removes prunable (e.g. shut down) instances from the registry so they can be garbage collected.
23
+ # Instances are re-registered if they are started again.
24
+ # @return [Array] The pruned instances.
25
+ def prune
26
+ pruned = instances.select(&:prunable?)
27
+ instances.delete_if { |instance| pruned.include?(instance) }
28
+ pruned
29
+ end
30
+ end
31
+
32
+ # Whether the instance can be removed from the registry.
33
+ # @return [Boolean]
34
+ def prunable?
35
+ shutdown?
36
+ end
37
+
38
+ private
39
+
40
+ def register_instance
41
+ instances = self.class.instances
42
+ instances << self unless instances.include?(self)
43
+ end
44
+ end
45
+ end