good_job 3.28.3 → 3.29.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.
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