good_job 3.28.3 → 3.29.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 38648843be987ab00d188e3564768440abc82e43ffe96b628431e34041152e8f
4
- data.tar.gz: 6d3a99426cc7fab00b03ef81116b84315a1bf634cb6b619f2263a4e367545271
3
+ metadata.gz: dba2ffb689b77716dac30f6c89c02a8ddd2cad9d340ca72fe86405d4cee446e1
4
+ data.tar.gz: 43c511926416fe1360e312ceb375b7db10e9c95ed9c8db040d9828e497b77eb0
5
5
  SHA512:
6
- metadata.gz: b3931885de25ffa50f38fa39045238f5a38e4c57f695b8f3e94a1042db09eaf8a87cf3f46b7e01cf498cad60667c63ab1f9bc3c95d8ccc8fb5857cec16e66032
7
- data.tar.gz: de8333dad6dd69694212e753642667c0be22dd72c39db530369ce4932d438d090e92139a0eb03163e3cd563bf91c21506725a068279e068a14f5ba31c8720e16
6
+ metadata.gz: b52422348cb25a90c17b755978b13278591ee5e388e3b16648fcd89fd77f6570a796ec44d7c64d4e9b0111b5102f2b80edfcbcd9236d13a2394a9ea5cc32297a
7
+ data.tar.gz: 91fab8760f5fbf4ff137fc66da09ead108201d0a47dd5d6859a4afed9d66b88f26c758ed95e4243591e6b719d417d7fa1d9ae3a85e5d3619f6c1b374435996c6
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## [v3.29.0](https://github.com/bensheldon/good_job/tree/v3.29.0) (2024-05-22)
4
+
5
+ [Full Changelog](https://github.com/bensheldon/good_job/compare/v3.28.3...v3.29.0)
6
+
7
+ **Merged pull requests:**
8
+
9
+ - Add association between Process and Jobs, and add a heartbeat to the Process record [\#999](https://github.com/bensheldon/good_job/pull/999) ([bensheldon](https://github.com/bensheldon))
10
+
3
11
  ## [v3.28.3](https://github.com/bensheldon/good_job/tree/v3.28.3) (2024-05-18)
4
12
 
5
13
  [Full Changelog](https://github.com/bensheldon/good_job/compare/v3.28.2...v3.28.3)
@@ -84,6 +84,13 @@ module GoodJob
84
84
  migration_pending_warning!
85
85
  false
86
86
  end
87
+
88
+ def process_lock_migrated?
89
+ return true if connection.index_name_exists?(:good_job_executions, :index_good_job_executions_on_process_id_and_created_at)
90
+
91
+ migration_pending_warning!
92
+ false
93
+ end
87
94
  end
88
95
 
89
96
  # The ActiveJob job class, as a string
@@ -262,7 +262,7 @@ module GoodJob
262
262
  # return value for the job's +#perform+ method, and the exception the job
263
263
  # raised, if any (if the job raised, then the second array entry will be
264
264
  # +nil+). If there were no jobs to execute, returns +nil+.
265
- def self.perform_with_advisory_lock(parsed_queues: nil, queue_select_limit: nil)
265
+ def self.perform_with_advisory_lock(lock_id:, parsed_queues: nil, queue_select_limit: nil)
266
266
  execution = nil
267
267
  result = nil
268
268
 
@@ -270,7 +270,7 @@ module GoodJob
270
270
  execution = executions.first
271
271
  if execution&.executable?
272
272
  yield(execution) if block_given?
273
- result = execution.perform
273
+ result = execution.perform(lock_id: lock_id)
274
274
  else
275
275
  execution = nil
276
276
  yield(nil) if block_given?
@@ -367,7 +367,7 @@ module GoodJob
367
367
  # An array of the return value of the job's +#perform+ method and the
368
368
  # exception raised by the job, if any. If the job completed successfully,
369
369
  # the second array entry (the exception) will be +nil+ and vice versa.
370
- def perform
370
+ def perform(lock_id:)
371
371
  run_callbacks(:perform) do
372
372
  raise PreviouslyPerformedError, 'Cannot perform a job that has already been performed' if finished_at
373
373
 
@@ -397,17 +397,37 @@ module GoodJob
397
397
 
398
398
  if discrete?
399
399
  transaction do
400
- discrete_execution = discrete_executions.create!(
400
+ discrete_execution_attrs = {
401
401
  job_class: job_class,
402
402
  queue_name: queue_name,
403
403
  serialized_params: serialized_params,
404
404
  scheduled_at: (scheduled_at || created_at),
405
- created_at: job_performed_at
406
- )
407
- update!(performed_at: job_performed_at, executions_count: ((executions_count || 0) + 1))
405
+ created_at: job_performed_at,
406
+ }
407
+ discrete_execution_attrs[:process_id] = lock_id if GoodJob::DiscreteExecution.columns_hash.key?("process_id")
408
+
409
+ execution_attrs = {
410
+ performed_at: job_performed_at,
411
+ executions_count: ((executions_count || 0) + 1),
412
+ }
413
+ if GoodJob::Execution.columns_hash.key?("locked_by_id")
414
+ execution_attrs[:locked_by_id] = lock_id
415
+ execution_attrs[:locked_at] = Time.current
416
+ end
417
+
418
+ discrete_execution = discrete_executions.create!(discrete_execution_attrs)
419
+ update!(execution_attrs)
408
420
  end
409
421
  else
410
- update!(performed_at: job_performed_at)
422
+ execution_attrs = {
423
+ performed_at: job_performed_at,
424
+ }
425
+ if GoodJob::Execution.columns_hash.key?("locked_by_id")
426
+ execution_attrs[:locked_by_id] = lock_id
427
+ execution_attrs[:locked_at] = Time.current
428
+ end
429
+
430
+ update!(execution_attrs)
411
431
  end
412
432
 
413
433
  ActiveSupport::Notifications.instrument("perform_job.good_job", { execution: self, process_id: current_thread.process_id, thread_name: current_thread.thread_name }) do |instrument_payload|
@@ -450,7 +470,11 @@ module GoodJob
450
470
  end
451
471
  end
452
472
 
453
- job_attributes = {}
473
+ job_attributes = if self.class.columns_hash.key?("locked_by_id")
474
+ { locked_by_id: nil, locked_at: nil }
475
+ else
476
+ {}
477
+ end
454
478
 
455
479
  job_error = result.handled_error || result.unhandled_error
456
480
  if job_error
@@ -29,6 +29,7 @@ module GoodJob
29
29
  self.implicit_order_column = 'created_at'
30
30
 
31
31
  belongs_to :batch, class_name: 'GoodJob::BatchRecord', inverse_of: :jobs, optional: true
32
+ belongs_to :locked_by_process, class_name: "GoodJob::Process", foreign_key: :locked_by_id, inverse_of: :locked_jobs, optional: true
32
33
  has_many :executions, -> { order(created_at: :asc) }, class_name: 'GoodJob::Execution', foreign_key: 'active_job_id', inverse_of: :job # rubocop:disable Rails/HasManyOrHasOneDependent
33
34
  has_many :discrete_executions, -> { order(created_at: :asc) }, class_name: 'GoodJob::DiscreteExecution', foreign_key: 'active_job_id', primary_key: :active_job_id, inverse_of: :job # rubocop:disable Rails/HasManyOrHasOneDependent
34
35
 
@@ -3,7 +3,7 @@
3
3
  require 'socket'
4
4
 
5
5
  module GoodJob # :nodoc:
6
- # ActiveRecord model that represents an GoodJob process (either async or CLI).
6
+ # Active Record model that represents a GoodJob capsule/process (either async or CLI).
7
7
  class Process < BaseRecord
8
8
  include AdvisoryLockable
9
9
  include OverridableConnection
@@ -15,50 +15,74 @@ module GoodJob # :nodoc:
15
15
 
16
16
  self.table_name = 'good_job_processes'
17
17
  self.implicit_order_column = 'created_at'
18
+ LOCK_TYPES = [
19
+ LOCK_TYPE_ADVISORY = 'advisory',
20
+ ].freeze
18
21
 
19
- cattr_reader :mutex, default: Mutex.new
20
- cattr_accessor :_current_id, default: nil
21
- cattr_accessor :_pid, default: nil
22
+ LOCK_TYPE_ENUMS = {
23
+ LOCK_TYPE_ADVISORY => 1,
24
+ }.freeze
25
+
26
+ self.table_name = 'good_job_processes'
27
+
28
+ has_many :locked_jobs, class_name: "GoodJob::Job", foreign_key: :locked_by_id, inverse_of: :locked_by_process, dependent: nil
29
+ after_destroy { locked_jobs.update_all(locked_by_id: nil) if GoodJob::Job.columns_hash.key?("locked_by_id") } # rubocop:disable Rails/SkipsModelValidations
22
30
 
23
31
  # Processes that are active and locked.
24
32
  # @!method active
25
33
  # @!scope class
26
34
  # @return [ActiveRecord::Relation]
27
- scope :active, -> { advisory_locked }
35
+ scope :active, (lambda do
36
+ if lock_type_migrated?
37
+ query = joins_advisory_locks
38
+ query.where(lock_type: LOCK_TYPE_ENUMS[LOCK_TYPE_ADVISORY]).advisory_locked
39
+ .or(query.where(lock_type: nil).where(arel_table[:updated_at].gt(EXPIRED_INTERVAL.ago)))
40
+ else
41
+ advisory_locked
42
+ end
43
+ end)
28
44
 
29
45
  # Processes that are inactive and unlocked (e.g. SIGKILLed)
30
46
  # @!method active
31
47
  # @!scope class
32
48
  # @return [ActiveRecord::Relation]
33
- scope :inactive, -> { advisory_unlocked }
34
-
35
- # UUID that is unique to the current process and changes when forked.
36
- # @return [String]
37
- def self.current_id
38
- mutex.synchronize { ns_current_id }
39
- end
49
+ scope :inactive, (lambda do
50
+ if lock_type_migrated?
51
+ query = joins_advisory_locks
52
+ query.where(lock_type: LOCK_TYPE_ENUMS[LOCK_TYPE_ADVISORY]).advisory_unlocked
53
+ .or(query.where(lock_type: nil).where(arel_table[:updated_at].lt(EXPIRED_INTERVAL.ago)))
54
+ else
55
+ advisory_unlocked
56
+ end
57
+ end)
40
58
 
41
- def self.ns_current_id
42
- if _current_id.nil? || _pid != ::Process.pid
43
- self._current_id = SecureRandom.uuid
44
- self._pid = ::Process.pid
59
+ # Deletes all inactive process records.
60
+ def self.cleanup
61
+ inactive.find_each do |process|
62
+ GoodJob::Job.where(locked_by_id: process.id).update_all(locked_by_id: nil, locked_at: nil) if GoodJob::Job.columns_hash.key?("locked_by_id") # rubocop:disable Rails/SkipsModelValidations
63
+ process.delete
45
64
  end
46
- _current_id
47
65
  end
48
66
 
49
- # Hash representing metadata about the current process.
50
- # @return [Hash]
51
- def self.current_state
52
- mutex.synchronize { ns_current_state }
67
+ # @return [Boolean]
68
+ def self.lock_type_migrated?
69
+ columns_hash["lock_type"].present?
53
70
  end
54
71
 
55
- def self.ns_current_state
56
- total_succeeded_executions_count = GoodJob::Scheduler.instances.sum { |scheduler| scheduler.stats.fetch(:succeeded_executions_count, 0) }
57
- total_errored_executions_count = GoodJob::Scheduler.instances.sum { |scheduler| scheduler.stats.fetch(:errored_executions_count, 0) }
58
- total_empty_executions_count = GoodJob::Scheduler.instances.sum { |scheduler| scheduler.stats.fetch(:empty_executions_count, 0) }
72
+ def self.create_record(id:, with_advisory_lock: false)
73
+ attributes = {
74
+ id: id,
75
+ state: process_state,
76
+ }
77
+ if with_advisory_lock
78
+ attributes[:create_with_advisory_lock] = true
79
+ attributes[:lock_type] = LOCK_TYPE_ADVISORY if lock_type_migrated?
80
+ end
81
+ create!(attributes)
82
+ end
59
83
 
84
+ def self.process_state
60
85
  {
61
- id: ns_current_id,
62
86
  hostname: Socket.gethostname,
63
87
  pid: ::Process.pid,
64
88
  proctitle: $PROGRAM_NAME,
@@ -66,10 +90,8 @@ module GoodJob # :nodoc:
66
90
  retry_on_unhandled_error: GoodJob.retry_on_unhandled_error,
67
91
  schedulers: GoodJob::Scheduler.instances.map(&:stats),
68
92
  cron_enabled: GoodJob.configuration.enable_cron?,
69
- total_succeeded_executions_count: total_succeeded_executions_count,
70
- total_errored_executions_count: total_errored_executions_count,
71
- total_executions_count: total_succeeded_executions_count + total_errored_executions_count,
72
- total_empty_executions_count: total_empty_executions_count,
93
+ total_succeeded_executions_count: GoodJob::Scheduler.instances.sum { |scheduler| scheduler.stats.fetch(:succeeded_executions_count) },
94
+ total_errored_executions_count: GoodJob::Scheduler.instances.sum { |scheduler| scheduler.stats.fetch(:errored_executions_count) },
73
95
  database_connection_pool: {
74
96
  size: connection_pool.size,
75
97
  active: connection_pool.connections.count(&:in_use?),
@@ -77,45 +99,36 @@ module GoodJob # :nodoc:
77
99
  }
78
100
  end
79
101
 
80
- # Deletes all inactive process records.
81
- def self.cleanup
82
- inactive.delete_all
83
- end
84
-
85
- # Registers the current process in the database
86
- # @return [GoodJob::Process]
87
- def self.register
88
- mutex.synchronize do
89
- process_state = ns_current_state
90
- create(id: process_state[:id], state: process_state, create_with_advisory_lock: true)
91
- rescue ActiveRecord::RecordNotUnique
92
- find(ns_current_state[:id])
93
- end
94
- end
95
-
96
102
  def refresh
97
- mutex.synchronize do
98
- reload
99
- update(state: self.class.ns_current_state, updated_at: Time.current)
100
- rescue ActiveRecord::RecordNotFound
101
- false
102
- end
103
+ self.state = self.class.process_state
104
+ reload.update(state: state, updated_at: Time.current)
105
+ rescue ActiveRecord::RecordNotFound
106
+ @new_record = true
107
+ self.created_at = self.updated_at = nil
108
+ state_will_change!
109
+ save
103
110
  end
104
111
 
105
- # Unregisters the instance.
106
- def deregister
107
- return unless owns_advisory_lock?
112
+ def refresh_if_stale(cleanup: false)
113
+ return unless stale?
108
114
 
109
- mutex.synchronize do
110
- destroy!
111
- advisory_unlock
112
- end
115
+ result = refresh
116
+ self.class.cleanup if cleanup
117
+ result
113
118
  end
114
119
 
115
120
  def state
116
121
  super || {}
117
122
  end
118
123
 
124
+ def stale?
125
+ updated_at < STALE_INTERVAL.ago
126
+ end
127
+
128
+ def expired?
129
+ updated_at < EXPIRED_INTERVAL.ago
130
+ end
131
+
119
132
  def basename
120
133
  File.basename(state.fetch("proctitle", ""))
121
134
  end
@@ -124,20 +137,20 @@ module GoodJob # :nodoc:
124
137
  state.fetch("schedulers", [])
125
138
  end
126
139
 
127
- def refresh_if_stale(cleanup: false)
128
- return unless stale?
140
+ def lock_type
141
+ return unless self.class.columns_hash['lock_type']
129
142
 
130
- result = refresh
131
- self.class.cleanup if cleanup
132
- result
143
+ enum = super
144
+ LOCK_TYPE_ENUMS.key(enum) if enum
133
145
  end
134
146
 
135
- def stale?
136
- updated_at < STALE_INTERVAL.ago
137
- end
147
+ def lock_type=(value)
148
+ return unless self.class.columns_hash['lock_type']
138
149
 
139
- def expired?
140
- updated_at < EXPIRED_INTERVAL.ago
150
+ enum = LOCK_TYPE_ENUMS[value]
151
+ raise(ArgumentError, "Invalid error_event: #{value}") if value && !enum
152
+
153
+ super(enum)
141
154
  end
142
155
  end
143
156
  end
@@ -30,6 +30,8 @@ class CreateGoodJobs < ActiveRecord::Migration<%= migration_version %>
30
30
  t.text :job_class
31
31
  t.integer :error_event, limit: 2
32
32
  t.text :labels, array: true
33
+ t.uuid :locked_by_id
34
+ t.datetime :locked_at
33
35
  end
34
36
 
35
37
  create_table :good_job_batches, id: :uuid do |t|
@@ -58,11 +60,13 @@ class CreateGoodJobs < ActiveRecord::Migration<%= migration_version %>
58
60
  t.text :error
59
61
  t.integer :error_event, limit: 2
60
62
  t.text :error_backtrace, array: true
63
+ t.uuid :process_id
61
64
  end
62
65
 
63
66
  create_table :good_job_processes, id: :uuid do |t|
64
67
  t.timestamps
65
68
  t.jsonb :state
69
+ t.integer :lock_type, limit: 2
66
70
  end
67
71
 
68
72
  create_table :good_job_settings, id: :uuid do |t|
@@ -88,5 +92,10 @@ class CreateGoodJobs < ActiveRecord::Migration<%= migration_version %>
88
92
  add_index :good_jobs, :labels, using: :gin, where: "(labels IS NOT NULL)", name: :index_good_jobs_on_labels
89
93
 
90
94
  add_index :good_job_executions, [:active_job_id, :created_at], name: :index_good_job_executions_on_active_job_id_and_created_at
95
+ add_index :good_jobs, [:priority, :scheduled_at], order: { priority: "ASC NULLS LAST", scheduled_at: :asc },
96
+ where: "finished_at IS NULL AND locked_by_id IS NULL", name: :index_good_jobs_on_priority_scheduled_at_unfinished_unlocked
97
+ add_index :good_jobs, :locked_by_id,
98
+ where: "locked_by_id IS NOT NULL", name: "index_good_jobs_on_locked_by_id"
99
+ add_index :good_job_executions, [:process_id, :created_at], name: :index_good_job_executions_on_process_id_and_created_at
91
100
  end
92
101
  end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+ class CreateGoodJobProcessLockIds < ActiveRecord::Migration<%= migration_version %>
3
+ def change
4
+ reversible do |dir|
5
+ dir.up do
6
+ # Ensure this incremental update migration is idempotent
7
+ # with monolithic install migration.
8
+ return if connection.column_exists?(:good_jobs, :locked_by_id)
9
+ end
10
+ end
11
+
12
+ add_column :good_jobs, :locked_by_id, :uuid
13
+ add_column :good_jobs, :locked_at, :datetime
14
+ add_column :good_job_executions, :process_id, :uuid
15
+ add_column :good_job_processes, :lock_type, :integer, limit: 2
16
+ end
17
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+ class CreateGoodJobProcessLockIndexes < ActiveRecord::Migration<%= migration_version %>
3
+ disable_ddl_transaction!
4
+
5
+ def change
6
+ reversible do |dir|
7
+ dir.up do
8
+ unless connection.index_name_exists?(:good_jobs, :index_good_jobs_on_priority_scheduled_at_unfinished_unlocked)
9
+ add_index :good_jobs, [:priority, :scheduled_at],
10
+ order: { priority: "ASC NULLS LAST", scheduled_at: :asc },
11
+ where: "finished_at IS NULL AND locked_by_id IS NULL",
12
+ name: :index_good_jobs_on_priority_scheduled_at_unfinished_unlocked,
13
+ algorithm: :concurrently
14
+ end
15
+
16
+ unless connection.index_name_exists?(:good_jobs, :index_good_jobs_on_locked_by_id)
17
+ add_index :good_jobs, :locked_by_id,
18
+ where: "locked_by_id IS NOT NULL",
19
+ name: :index_good_jobs_on_locked_by_id,
20
+ algorithm: :concurrently
21
+ end
22
+
23
+ unless connection.index_name_exists?(:good_job_executions, :index_good_job_executions_on_process_id_and_created_at)
24
+ add_index :good_job_executions, [:process_id, :created_at],
25
+ name: :index_good_job_executions_on_process_id_and_created_at,
26
+ algorithm: :concurrently
27
+ end
28
+ end
29
+
30
+ dir.down do
31
+ remove_index(:good_jobs, name: :index_good_jobs_on_priority_scheduled_at_unfinished_unlocked) if connection.index_name_exists?(:good_jobs, :index_good_jobs_on_priority_scheduled_at_unfinished_unlocked)
32
+ remove_index(:good_jobs, name: :index_good_jobs_on_locked_by_id) if connection.index_name_exists?(:good_jobs, :index_good_jobs_on_locked_by_id)
33
+ remove_index(:good_job_executions, name: :index_good_job_executions_on_process_id_and_created_at) if connection.index_name_exists?(:good_job_executions, :index_good_job_executions_on_process_id_and_created_at)
34
+ end
35
+ end
36
+ end
37
+ end
@@ -97,16 +97,17 @@ module GoodJob
97
97
  end
98
98
  end
99
99
 
100
+ @capsule.tracker.register
100
101
  begin
101
102
  until inline_executions.empty?
102
103
  begin
103
104
  inline_execution = inline_executions.shift
104
- inline_result = inline_execution.perform
105
+ inline_result = inline_execution.perform(lock_id: @capsule.tracker.id_for_lock)
105
106
 
106
107
  retried_execution = inline_result.retried
107
108
  while retried_execution && retried_execution.scheduled_at <= Time.current
108
109
  inline_execution = retried_execution
109
- inline_result = inline_execution.perform
110
+ inline_result = inline_execution.perform(lock_id: @capsule.tracker.id_for_lock)
110
111
  retried_execution = inline_result.retried
111
112
  end
112
113
  ensure
@@ -116,6 +117,7 @@ module GoodJob
116
117
  raise inline_result.unhandled_error if inline_result.unhandled_error
117
118
  end
118
119
  ensure
120
+ @capsule.tracker.unregister
119
121
  inline_executions.each(&:advisory_unlock)
120
122
  end
121
123
 
@@ -168,12 +170,12 @@ module GoodJob
168
170
  create_with_advisory_lock: true
169
171
  )
170
172
  begin
171
- result = execution.perform
173
+ result = @capsule.tracker.register { execution.perform(lock_id: @capsule.tracker.id_for_lock) }
172
174
 
173
175
  retried_execution = result.retried
174
176
  while retried_execution && (retried_execution.scheduled_at.nil? || retried_execution.scheduled_at <= Time.current)
175
177
  execution = retried_execution
176
- result = execution.perform
178
+ result = @capsule.tracker.register { execution.perform(lock_id: @capsule.tracker.id_for_lock) }
177
179
  retried_execution = result.retried
178
180
  end
179
181
 
@@ -11,6 +11,10 @@ module GoodJob
11
11
  # @return [Array<GoodJob::Capsule>, nil]
12
12
  cattr_reader :instances, default: Concurrent::Array.new, instance_reader: false
13
13
 
14
+ delegate :register, :renew, :unregister, :id_for_lock, to: :@tracker, prefix: :_tracker
15
+
16
+ attr_reader :tracker
17
+
14
18
  # @param configuration [GoodJob::Configuration] Configuration to use for this capsule.
15
19
  def initialize(configuration: nil)
16
20
  @configuration = configuration
@@ -18,6 +22,9 @@ module GoodJob
18
22
  @started_at = nil
19
23
  @mutex = Mutex.new
20
24
 
25
+ @shared_executor = GoodJob::SharedExecutor.new
26
+ @tracker = GoodJob::CapsuleTracker.new(executor: @shared_executor)
27
+
21
28
  self.class.instances << self
22
29
  end
23
30
 
@@ -29,15 +36,13 @@ module GoodJob
29
36
  @mutex.synchronize do
30
37
  return unless startable?(force: force)
31
38
 
32
- @shared_executor = GoodJob::SharedExecutor.new
33
- @notifier = GoodJob::Notifier.new(enable_listening: configuration.enable_listen_notify, executor: @shared_executor.executor)
39
+ @notifier = GoodJob::Notifier.new(enable_listening: configuration.enable_listen_notify, capsule: self, executor: @shared_executor)
34
40
  @poller = GoodJob::Poller.new(poll_interval: configuration.poll_interval)
35
- @multi_scheduler = GoodJob::MultiScheduler.from_configuration(configuration, warm_cache_on_initialize: true)
41
+ @multi_scheduler = GoodJob::MultiScheduler.from_configuration(configuration, capsule: self, warm_cache_on_initialize: true)
36
42
  @notifier.recipients.push([@multi_scheduler, :create_thread])
37
43
  @poller.recipients.push(-> { @multi_scheduler.create_thread({ fanout: true }) })
38
44
 
39
- @cron_manager = GoodJob::CronManager.new(configuration.cron_entries, start_on_initialize: true, executor: @shared_executor.executor) if configuration.enable_cron?
40
-
45
+ @cron_manager = GoodJob::CronManager.new(configuration.cron_entries, start_on_initialize: true, executor: @shared_executor) if configuration.enable_cron?
41
46
  @startable = false
42
47
  @started_at = Time.current
43
48
  end
@@ -52,7 +57,7 @@ module GoodJob
52
57
  # @return [void]
53
58
  def shutdown(timeout: NONE)
54
59
  timeout = configuration.shutdown_timeout if timeout == NONE
55
- GoodJob._shutdown_all([@shared_executor, @notifier, @poller, @multi_scheduler, @cron_manager].compact, timeout: timeout)
60
+ GoodJob._shutdown_all([@notifier, @poller, @multi_scheduler, @cron_manager].compact, after: [@shared_executor], timeout: timeout)
56
61
  @startable = false
57
62
  @started_at = nil
58
63
  end
@@ -74,7 +79,7 @@ module GoodJob
74
79
 
75
80
  # @return [Boolean] Whether the capsule has been shutdown.
76
81
  def shutdown?
77
- [@shared_executor, @notifier, @poller, @multi_scheduler, @cron_manager].compact.all?(&:shutdown?)
82
+ [@notifier, @poller, @multi_scheduler, @cron_manager].compact.all?(&:shutdown?)
78
83
  end
79
84
 
80
85
  # @param duration [nil, Numeric] Length of idleness to check for (in seconds).
@@ -99,6 +104,12 @@ module GoodJob
99
104
  @multi_scheduler&.create_thread(job_state)
100
105
  end
101
106
 
107
+ # UUID for this capsule; to be used for inspection (not directly for locking jobs).
108
+ # @return [String]
109
+ def process_id
110
+ @tracker.process_id
111
+ end
112
+
102
113
  private
103
114
 
104
115
  def configuration
@@ -0,0 +1,227 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoodJob # :nodoc:
4
+ # CapsuleTracker save a record in the database and periodically refreshes it. The intention is to
5
+ # create a heartbeat that can be used to determine whether a capsule/process is still active
6
+ # and use that to lock (or unlock) jobs.
7
+ class CapsuleTracker
8
+ # The database record used for tracking.
9
+ # @return [GoodJob::Process, nil]
10
+ attr_reader :record
11
+
12
+ # Number of tracked job executions.
13
+ attr_reader :locks
14
+
15
+ # Number of tracked job executions with advisory locks.
16
+ # @return [Integer]
17
+ attr_reader :advisory_locks
18
+
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
24
+
25
+ # @param executor [Concurrent::AbstractExecutorService] The executor to use for refreshing the process record.
26
+ def initialize(executor: Concurrent.global_io_executor)
27
+ @executor = executor
28
+ @mutex = Mutex.new
29
+ @locks = 0
30
+ @advisory_locked_connection = nil
31
+ @record_id = SecureRandom.uuid
32
+ @record = nil
33
+ @refresh_task = nil
34
+
35
+ # AS::ForkTracker is only present on Rails v6.1+.
36
+ # Fall back to PID checking if ForkTracker is not available
37
+ if defined?(ActiveSupport::ForkTracker)
38
+ ActiveSupport::ForkTracker.after_fork { reset }
39
+ @forktracker = true
40
+ else
41
+ @ruby_pid = ::Process.pid
42
+ @forktracker = false
43
+ end
44
+
45
+ self.class.instances << self
46
+ end
47
+
48
+ # The UUID to use for locking. May be nil if the process is not registered or is unusable/expired.
49
+ # If UUID has not yet been persisted to the database, this method will make a query to insert or update it.
50
+ # @return [String, nil]
51
+ def id_for_lock
52
+ value = nil
53
+ synchronize do
54
+ next if @locks.zero?
55
+
56
+ reset_on_fork
57
+ if @record
58
+ @record.refresh_if_stale
59
+ else
60
+ @record = GoodJob::Process.create_record(id: @record_id)
61
+ create_refresh_task
62
+ end
63
+ value = @record&.id
64
+ end
65
+ value
66
+ end
67
+
68
+ # The expected UUID of the process for use in inspection.
69
+ # Use {#id_for_lock} if using this as a lock key.
70
+ # @return [String]
71
+ def process_id
72
+ @record_id
73
+ end
74
+
75
+ # Registers the current process around a job execution site.
76
+ # +register+ is expected to be called multiple times in a process, but should be advisory locked only once (in a single thread).
77
+ # @param with_advisory_lock [Boolean] Whether the lock strategy should us an advisory lock; the connection must be retained to support advisory locks.
78
+ # @yield [void] If a block is given, the process will be unregistered after the block completes.
79
+ # @return [void]
80
+ def register(with_advisory_lock: false)
81
+ synchronize do
82
+ if with_advisory_lock
83
+ if @record
84
+ if !advisory_locked? || !advisory_locked_connection?
85
+ @record.class.transaction do
86
+ @record.advisory_lock!
87
+ @record.update(lock_type: GoodJob::Process::LOCK_TYPE_ADVISORY)
88
+ end
89
+ @advisory_locked_connection = WeakRef.new(@record.class.connection)
90
+ end
91
+ else
92
+ @record = GoodJob::Process.create_record(id: @record_id, with_advisory_lock: true)
93
+ @advisory_locked_connection = WeakRef.new(@record.class.connection)
94
+ create_refresh_task
95
+ end
96
+ end
97
+
98
+ @locks += 1
99
+ end
100
+ return unless block_given?
101
+
102
+ begin
103
+ yield
104
+ ensure
105
+ unregister(with_advisory_lock: with_advisory_lock)
106
+ end
107
+ end
108
+
109
+ # Unregisters the current process from the database.
110
+ # @param with_advisory_lock [Boolean] Whether the lock strategy should unlock an advisory lock; the connection must be able to support advisory locks.
111
+ # @return [void]
112
+ def unregister(with_advisory_lock: false)
113
+ synchronize do
114
+ if @locks.zero?
115
+ return
116
+ elsif @locks == 1
117
+ if @record
118
+ if with_advisory_lock && advisory_locked? && advisory_locked_connection?
119
+ @record.class.transaction do
120
+ @record.advisory_unlock
121
+ @record.destroy
122
+ end
123
+ @advisory_locked_connection = nil
124
+ else
125
+ @record.destroy
126
+ end
127
+ @record = nil
128
+ end
129
+ cancel_refresh_task
130
+ elsif with_advisory_lock && advisory_locked? && advisory_locked_connection?
131
+ @record.class.transaction do
132
+ @record.advisory_unlock
133
+ @record.update(lock_type: nil)
134
+ end
135
+ @advisory_locked_connection = nil
136
+ end
137
+
138
+ @locks -= 1 unless @locks.zero?
139
+ end
140
+ end
141
+
142
+ # Refreshes the process record in the database.
143
+ # @param silent [Boolean] Whether to silence logging.
144
+ # @return [void]
145
+ def renew(silent: false)
146
+ GoodJob::Process.with_logger_silenced(silent: silent) do
147
+ @record&.refresh_if_stale(cleanup: true)
148
+ end
149
+ end
150
+
151
+ # Tests whether an active advisory lock has been taken on the record.
152
+ # @return [Boolean]
153
+ def advisory_locked?
154
+ @advisory_locked_connection&.weakref_alive? && @advisory_locked_connection&.active?
155
+ end
156
+
157
+ # @!visibility private
158
+ def task_observer(_time, _output, thread_error)
159
+ GoodJob._on_thread_error(thread_error) if thread_error && !thread_error.is_a?(Concurrent::CancelledOperationError)
160
+ end
161
+
162
+ private
163
+
164
+ def advisory_locked_connection?
165
+ @record&.class&.connection && @advisory_locked_connection&.weakref_alive? && @advisory_locked_connection.eql?(@record.class.connection)
166
+ end
167
+
168
+ def task_interval
169
+ GoodJob::Process::STALE_INTERVAL + jitter
170
+ end
171
+
172
+ def jitter
173
+ GoodJob::Process::STALE_INTERVAL * 0.1 * Kernel.rand
174
+ end
175
+
176
+ def create_refresh_task(delay: nil)
177
+ return if @refresh_task
178
+ return unless @executor
179
+
180
+ delay ||= task_interval
181
+ @refresh_task = Concurrent::ScheduledTask.new(delay.to_f, executor: @executor) do
182
+ Rails.application.executor.wrap do
183
+ synchronize do
184
+ next unless @locks.positive?
185
+
186
+ @refresh_task = nil
187
+ create_refresh_task
188
+ renew(silent: true)
189
+ end
190
+ end
191
+ end
192
+ @refresh_task.add_observer(self, :task_observer)
193
+ @refresh_task.execute
194
+ end
195
+
196
+ def cancel_refresh_task
197
+ @refresh_task&.cancel
198
+ @refresh_task = nil
199
+ end
200
+
201
+ def reset
202
+ synchronize { ns_reset }
203
+ end
204
+
205
+ def reset_on_fork
206
+ return if Concurrent.on_jruby?
207
+ return if @forktracker || ::Process.pid == @ruby_pid
208
+
209
+ @ruby_pid = ::Process.pid
210
+ ns_reset
211
+ end
212
+
213
+ def ns_reset
214
+ @record_id = SecureRandom.uuid
215
+ @record = nil
216
+ end
217
+
218
+ # Synchronize must always be called from within a Rails Executor; it may deadlock if the order is reversed.
219
+ def synchronize(&block)
220
+ if @mutex.owned?
221
+ yield
222
+ else
223
+ @mutex.synchronize(&block)
224
+ end
225
+ end
226
+ end
227
+ end
@@ -14,8 +14,9 @@ module GoodJob
14
14
  cattr_accessor :performing_active_job_ids, default: Concurrent::Set.new
15
15
 
16
16
  # @param queue_string [String] Queues to execute jobs from
17
- def initialize(queue_string)
17
+ def initialize(queue_string, capsule: GoodJob.capsule)
18
18
  @queue_string = queue_string
19
+ @capsule = capsule
19
20
  @metrics = Metrics.new
20
21
  end
21
22
 
@@ -30,20 +31,22 @@ module GoodJob
30
31
  # @return [Object, nil] Returns job result or +nil+ if no job was found
31
32
  def next
32
33
  active_job_id = nil
33
- job_query.perform_with_advisory_lock(parsed_queues: parsed_queues, queue_select_limit: GoodJob.configuration.queue_select_limit) do |execution|
34
- @metrics.touch_check_queue_at
35
-
36
- if execution
37
- active_job_id = execution.active_job_id
38
- performing_active_job_ids << active_job_id
39
- @metrics.touch_execution_at
40
- yield(execution) if block_given?
41
- else
42
- @metrics.increment_empty_executions
43
- end
44
- end.tap do |result|
45
- if result
46
- result.succeeded? ? @metrics.increment_succeeded_executions : @metrics.increment_errored_executions
34
+ @capsule.tracker.register do
35
+ job_query.perform_with_advisory_lock(lock_id: @capsule.tracker.id_for_lock, parsed_queues: parsed_queues, queue_select_limit: GoodJob.configuration.queue_select_limit) do |execution|
36
+ @metrics.touch_check_queue_at
37
+
38
+ if execution
39
+ active_job_id = execution.active_job_id
40
+ performing_active_job_ids << active_job_id
41
+ @metrics.touch_execution_at
42
+ yield(execution) if block_given?
43
+ else
44
+ @metrics.increment_empty_executions
45
+ end
46
+ end.tap do |result|
47
+ if result
48
+ result.succeeded? ? @metrics.increment_succeeded_executions : @metrics.increment_errored_executions
49
+ end
47
50
  end
48
51
  end
49
52
  ensure
@@ -7,12 +7,12 @@ module GoodJob
7
7
  # @param configuration [GoodJob::Configuration]
8
8
  # @param warm_cache_on_initialize [Boolean]
9
9
  # @return [GoodJob::MultiScheduler]
10
- def self.from_configuration(configuration, warm_cache_on_initialize: false)
10
+ def self.from_configuration(configuration, capsule: GoodJob.capsule, warm_cache_on_initialize: false)
11
11
  schedulers = configuration.queue_string.split(';').map(&:strip).map do |queue_string_and_max_threads|
12
12
  queue_string, max_threads = queue_string_and_max_threads.split(':').map { |str| str.strip.presence }
13
13
  max_threads = (max_threads || configuration.max_threads).to_i
14
14
 
15
- job_performer = GoodJob::JobPerformer.new(queue_string)
15
+ job_performer = GoodJob::JobPerformer.new(queue_string, capsule: capsule)
16
16
  GoodJob::Scheduler.new(
17
17
  job_performer,
18
18
  max_threads: max_threads,
@@ -16,7 +16,7 @@ module GoodJob # :nodoc:
16
16
  def register_process
17
17
  GoodJob::Process.override_connection(connection) do
18
18
  GoodJob::Process.cleanup
19
- @process = GoodJob::Process.register
19
+ @capsule.tracker.register(with_advisory_lock: true)
20
20
  end
21
21
  end
22
22
 
@@ -24,7 +24,7 @@ module GoodJob # :nodoc:
24
24
  Rails.application.executor.wrap do
25
25
  GoodJob::Process.override_connection(connection) do
26
26
  GoodJob::Process.with_logger_silenced do
27
- @process&.refresh_if_stale(cleanup: true)
27
+ @capsule.tracker.renew
28
28
  end
29
29
  end
30
30
  end
@@ -33,7 +33,7 @@ module GoodJob # :nodoc:
33
33
  # Deregisters the current process.
34
34
  def deregister_process
35
35
  GoodJob::Process.override_connection(connection) do
36
- @process&.deregister
36
+ @capsule.tracker.unregister(with_advisory_lock: true)
37
37
  end
38
38
  end
39
39
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'active_support/core_ext/module/attribute_accessors_per_thread'
4
4
  require 'concurrent/atomic/atomic_boolean'
5
+ require "concurrent/scheduled_task"
5
6
  require "good_job/notifier/process_heartbeat"
6
7
 
7
8
  module GoodJob # :nodoc:
@@ -62,13 +63,12 @@ module GoodJob # :nodoc:
62
63
 
63
64
  # @param recipients [Array<#call, Array(Object, Symbol)>]
64
65
  # @param enable_listening [true, false]
65
- # @param executor [Concurrent::ExecutorService]
66
- def initialize(*recipients, enable_listening: true, executor: Concurrent.global_io_executor)
66
+ def initialize(*recipients, enable_listening: true, capsule: GoodJob.capsule, executor: Concurrent.global_io_executor)
67
67
  @recipients = Concurrent::Array.new(recipients)
68
68
  @enable_listening = enable_listening
69
69
  @executor = executor
70
70
 
71
- @mutex = Mutex.new
71
+ @monitor = Monitor.new
72
72
  @shutdown_event = Concurrent::Event.new.tap(&:set)
73
73
  @running = Concurrent::AtomicBoolean.new(false)
74
74
  @connected = Concurrent::Event.new
@@ -77,6 +77,7 @@ module GoodJob # :nodoc:
77
77
  @connection_errors_reported = Concurrent::AtomicBoolean.new(false)
78
78
  @enable_listening = enable_listening
79
79
  @task = nil
80
+ @capsule = capsule
80
81
 
81
82
  start
82
83
  self.class.instances << self
@@ -183,6 +184,8 @@ module GoodJob # :nodoc:
183
184
 
184
185
  private
185
186
 
187
+ delegate :synchronize, to: :@monitor
188
+
186
189
  def start
187
190
  synchronize do
188
191
  return if @running.true?
@@ -211,20 +214,20 @@ module GoodJob # :nodoc:
211
214
  end
212
215
 
213
216
  while thr_executor.running? && thr_running.true?
214
- run_callbacks :tick do
215
- wait_for_notify do |channel, payload|
216
- next unless channel == CHANNEL
217
-
218
- ActiveSupport::Notifications.instrument("notifier_notified.good_job", { payload: payload })
219
- parsed_payload = JSON.parse(payload, symbolize_names: true)
220
- thr_recipients.each do |recipient|
221
- target, method_name = recipient.is_a?(Array) ? recipient : [recipient, :call]
222
- target.send(method_name, parsed_payload)
223
- end
224
- end
217
+ Rails.application.executor.wrap { run_callbacks(:tick) }
225
218
 
226
- reset_connection_errors
219
+ wait_for_notify do |channel, payload|
220
+ next unless channel == CHANNEL
221
+
222
+ ActiveSupport::Notifications.instrument("notifier_notified.good_job", { payload: payload })
223
+ parsed_payload = JSON.parse(payload, symbolize_names: true)
224
+ thr_recipients.each do |recipient|
225
+ target, method_name = recipient.is_a?(Array) ? recipient : [recipient, :call]
226
+ target.send(method_name, parsed_payload)
227
+ end
227
228
  end
229
+
230
+ reset_connection_errors
228
231
  end
229
232
  end
230
233
  ensure
@@ -284,13 +287,5 @@ module GoodJob # :nodoc:
284
287
  @connection_errors_count.value = 0
285
288
  @connection_errors_reported.make_false
286
289
  end
287
-
288
- def synchronize(&block)
289
- if @mutex.owned?
290
- yield
291
- else
292
- @mutex.synchronize(&block)
293
- end
294
- end
295
290
  end
296
291
  end
@@ -1,7 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'concurrent/executor/executor_service'
4
+
3
5
  module GoodJob
4
6
  class SharedExecutor
7
+ # Allow posting tasks directly to instances
8
+ include Concurrent::ExecutorService
9
+
5
10
  MAX_THREADS = 2
6
11
 
7
12
  # @!attribute [r] instances
@@ -13,8 +18,21 @@ module GoodJob
13
18
  attr_reader :executor
14
19
 
15
20
  def initialize
21
+ @mutex = Mutex.new
22
+
16
23
  self.class.instances << self
17
- create_executor
24
+ end
25
+
26
+ def post(*args, &task)
27
+ unless running?
28
+ @mutex.synchronize do
29
+ next if running?
30
+
31
+ create_executor
32
+ end
33
+ end
34
+
35
+ @executor&.post(*args, &task)
18
36
  end
19
37
 
20
38
  def running?
@@ -2,7 +2,7 @@
2
2
 
3
3
  module GoodJob
4
4
  # GoodJob gem version.
5
- VERSION = '3.28.3'
5
+ VERSION = '3.29.0'
6
6
 
7
7
  # GoodJob version as Gem::Version object
8
8
  GEM_VERSION = Gem::Version.new(VERSION)
data/lib/good_job.rb CHANGED
@@ -19,6 +19,7 @@ require_relative "good_job/overridable_connection"
19
19
  require_relative "good_job/bulk"
20
20
  require_relative "good_job/callable"
21
21
  require_relative "good_job/capsule"
22
+ require_relative "good_job/capsule_tracker"
22
23
  require_relative "good_job/cleanup_tracker"
23
24
  require_relative "good_job/cli"
24
25
  require_relative "good_job/configuration"
@@ -169,11 +170,12 @@ module GoodJob
169
170
  end
170
171
 
171
172
  # Sends +#shutdown+ or +#restart+ to executable objects ({GoodJob::Notifier}, {GoodJob::Poller}, {GoodJob::Scheduler}, {GoodJob::MultiScheduler}, {GoodJob::CronManager})
172
- # @param executables [Array<Notifier, Poller, Scheduler, MultiScheduler, CronManager>] Objects to shut down.
173
+ # @param executables [Array<Notifier, Poller, Scheduler, MultiScheduler, CronManager, SharedExecutor>] Objects to shut down.
173
174
  # @param method_name [:symbol] Method to call, e.g. +:shutdown+ or +:restart+.
174
175
  # @param timeout [nil,Numeric]
176
+ # @param after [Array<Notifier, Poller, Scheduler, MultiScheduler, CronManager, SharedExecutor>] Objects to shut down after initial executables shut down.
175
177
  # @return [void]
176
- def self._shutdown_all(executables, method_name = :shutdown, timeout: -1)
178
+ def self._shutdown_all(executables, method_name = :shutdown, timeout: -1, after: [])
177
179
  if timeout.is_a?(Numeric) && timeout.positive?
178
180
  executables.each { |executable| executable.send(method_name, timeout: nil) }
179
181
 
@@ -182,6 +184,13 @@ module GoodJob
182
184
  else
183
185
  executables.each { |executable| executable.send(method_name, timeout: timeout) }
184
186
  end
187
+ return unless after.any? && !timeout.nil?
188
+
189
+ if stop_at
190
+ after.each { |executable| executable.shutdown(timeout: [stop_at - Time.current, 0].max) }
191
+ else
192
+ after.each { |executable| executable.shutdown(timeout: timeout) }
193
+ end
185
194
  end
186
195
 
187
196
  # Destroys preserved job and batch records.
@@ -277,8 +286,8 @@ module GoodJob
277
286
  # @return [Boolean]
278
287
  def self.migrated?
279
288
  # Always update with the most recent migration check
280
- GoodJob::DiscreteExecution.reset_column_information
281
- GoodJob::DiscreteExecution.backtrace_migrated?
289
+ GoodJob::Execution.reset_column_information
290
+ GoodJob::Execution.process_lock_migrated?
282
291
  end
283
292
 
284
293
  ActiveSupport.run_load_hooks(:good_job, self)
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: good_job
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.28.3
4
+ version: 3.29.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ben Sheldon
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2024-05-18 00:00:00.000000000 Z
11
+ date: 2024-05-22 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activejob
@@ -360,6 +360,8 @@ files:
360
360
  - lib/generators/good_job/templates/update/migrations/10_remove_good_job_active_id_index.rb.erb
361
361
  - lib/generators/good_job/templates/update/migrations/11_create_index_good_job_jobs_for_candidate_lookup.rb.erb
362
362
  - lib/generators/good_job/templates/update/migrations/12_create_good_job_execution_error_backtrace.rb.erb
363
+ - lib/generators/good_job/templates/update/migrations/13_create_good_job_process_lock_ids.rb.erb
364
+ - lib/generators/good_job/templates/update/migrations/14_create_good_job_process_lock_indexes.rb.erb
363
365
  - lib/generators/good_job/update_generator.rb
364
366
  - lib/good_job.rb
365
367
  - lib/good_job/active_job_extensions/batches.rb
@@ -371,6 +373,7 @@ files:
371
373
  - lib/good_job/bulk.rb
372
374
  - lib/good_job/callable.rb
373
375
  - lib/good_job/capsule.rb
376
+ - lib/good_job/capsule_tracker.rb
374
377
  - lib/good_job/cleanup_tracker.rb
375
378
  - lib/good_job/cli.rb
376
379
  - lib/good_job/configuration.rb