good_job 4.23.0 → 4.24.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 (101) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +33 -0
  3. data/README.md +7 -1
  4. data/app/charts/good_job/base_chart.rb +1 -0
  5. data/app/charts/good_job/latency_metric/base.rb +2 -1
  6. data/app/charts/good_job/latency_metric/execution.rb +1 -0
  7. data/app/charts/good_job/latency_metric/queue.rb +1 -0
  8. data/app/charts/good_job/latency_metric/total.rb +1 -0
  9. data/app/charts/good_job/latency_metric.rb +1 -0
  10. data/app/charts/good_job/performance_index_chart.rb +1 -0
  11. data/app/charts/good_job/performance_range.rb +5 -3
  12. data/app/charts/good_job/performance_show_chart.rb +1 -0
  13. data/app/charts/good_job/scheduled_by_queue_chart.rb +1 -0
  14. data/app/controllers/good_job/application_controller.rb +7 -4
  15. data/app/controllers/good_job/batches_controller.rb +1 -0
  16. data/app/controllers/good_job/cleaner_controller.rb +1 -0
  17. data/app/controllers/good_job/cron_entries_controller.rb +1 -0
  18. data/app/controllers/good_job/executions_controller.rb +1 -0
  19. data/app/controllers/good_job/frontends_controller.rb +9 -4
  20. data/app/controllers/good_job/jobs_controller.rb +1 -0
  21. data/app/controllers/good_job/metrics_controller.rb +1 -0
  22. data/app/controllers/good_job/pauses_controller.rb +1 -0
  23. data/app/controllers/good_job/performance_controller.rb +1 -0
  24. data/app/controllers/good_job/processes_controller.rb +1 -0
  25. data/app/filters/good_job/base_filter.rb +10 -1
  26. data/app/filters/good_job/batches_filter.rb +1 -0
  27. data/app/filters/good_job/jobs_filter.rb +4 -1
  28. data/app/helpers/good_job/application_helper.rb +1 -0
  29. data/app/helpers/good_job/icons_helper.rb +1 -0
  30. data/app/models/concerns/good_job/advisory_lockable.rb +23 -5
  31. data/app/models/concerns/good_job/error_events.rb +1 -0
  32. data/app/models/concerns/good_job/filterable.rb +1 -0
  33. data/app/models/concerns/good_job/filtered_arguments.rb +1 -0
  34. data/app/models/concerns/good_job/reportable.rb +1 -0
  35. data/app/models/good_job/active_record_parent_class.rb +1 -0
  36. data/app/models/good_job/base_record.rb +1 -0
  37. data/app/models/good_job/batch.rb +17 -16
  38. data/app/models/good_job/batch_record.rb +10 -8
  39. data/app/models/good_job/concurrency_claim.rb +1 -0
  40. data/app/models/good_job/cron_entry.rb +1 -0
  41. data/app/models/good_job/discrete_execution.rb +1 -0
  42. data/app/models/good_job/execution.rb +6 -3
  43. data/app/models/good_job/execution_result.rb +1 -0
  44. data/app/models/good_job/i18n_config.rb +1 -0
  45. data/app/models/good_job/job/lockable.rb +68 -42
  46. data/app/models/good_job/job.rb +20 -16
  47. data/app/models/good_job/process.rb +5 -5
  48. data/app/models/good_job/setting.rb +5 -2
  49. data/config/brakeman.ignore +9 -9
  50. data/lib/active_job/queue_adapters/good_job_adapter.rb +1 -0
  51. data/lib/generators/good_job/install_generator.rb +1 -0
  52. data/lib/generators/good_job/update_generator.rb +1 -0
  53. data/lib/good_job/active_job_extensions/batches.rb +1 -0
  54. data/lib/good_job/active_job_extensions/concurrency.rb +1 -0
  55. data/lib/good_job/active_job_extensions/interrupt_errors.rb +1 -0
  56. data/lib/good_job/active_job_extensions/labels.rb +1 -0
  57. data/lib/good_job/active_job_extensions/notify_options.rb +1 -0
  58. data/lib/good_job/adapter/inline_buffer.rb +1 -0
  59. data/lib/good_job/adapter.rb +25 -32
  60. data/lib/good_job/bulk.rb +1 -0
  61. data/lib/good_job/callable.rb +1 -0
  62. data/lib/good_job/capsule.rb +4 -0
  63. data/lib/good_job/capsule_tracker.rb +6 -6
  64. data/lib/good_job/cleanup_tracker.rb +1 -0
  65. data/lib/good_job/cli.rb +5 -0
  66. data/lib/good_job/configuration/validator.rb +1 -0
  67. data/lib/good_job/configuration.rb +13 -0
  68. data/lib/good_job/cron_manager.rb +1 -0
  69. data/lib/good_job/current_thread.rb +2 -1
  70. data/lib/good_job/daemon.rb +1 -0
  71. data/lib/good_job/dependencies.rb +1 -0
  72. data/lib/good_job/engine.rb +3 -1
  73. data/lib/good_job/instance_registry.rb +5 -2
  74. data/lib/good_job/interrupt_error.rb +1 -0
  75. data/lib/good_job/interrupted_error.rb +1 -0
  76. data/lib/good_job/job_performer/metrics.rb +1 -0
  77. data/lib/good_job/job_performer.rb +5 -4
  78. data/lib/good_job/lifecycle_hooks.rb +1 -0
  79. data/lib/good_job/log_subscriber.rb +1 -0
  80. data/lib/good_job/multi_scheduler.rb +1 -0
  81. data/lib/good_job/notifier/process_heartbeat.rb +1 -0
  82. data/lib/good_job/notifier.rb +1 -0
  83. data/lib/good_job/poller.rb +1 -0
  84. data/lib/good_job/probe_server/cluster_healthcheck_middleware.rb +1 -0
  85. data/lib/good_job/probe_server/healthcheck_middleware.rb +1 -0
  86. data/lib/good_job/probe_server/not_found_app.rb +1 -0
  87. data/lib/good_job/probe_server/simple_handler.rb +48 -11
  88. data/lib/good_job/probe_server/webrick_handler.rb +1 -0
  89. data/lib/good_job/probe_server.rb +1 -0
  90. data/lib/good_job/ractors.rb +1 -0
  91. data/lib/good_job/safe_state.rb +1 -0
  92. data/lib/good_job/scheduler.rb +1 -0
  93. data/lib/good_job/sd_notify.rb +1 -0
  94. data/lib/good_job/shared_executor.rb +1 -0
  95. data/lib/good_job/subprocess.rb +1 -0
  96. data/lib/good_job/supervisor.rb +22 -15
  97. data/lib/good_job/systemd_service.rb +1 -0
  98. data/lib/good_job/thread_status.rb +1 -0
  99. data/lib/good_job/version.rb +2 -1
  100. data/lib/good_job.rb +47 -21
  101. metadata +2 -2
@@ -1,3 +1,4 @@
1
+ # typed: false
1
2
  # frozen_string_literal: true
2
3
 
3
4
  module GoodJob
@@ -8,6 +9,11 @@ module GoodJob
8
9
  module Lockable
9
10
  extend ActiveSupport::Concern
10
11
 
12
+ # Seconds to wait between attempts to release a claim after a connection failure.
13
+ RELEASE_CLAIM_RETRY_INTERVAL = 5
14
+ # Log a warning on the first failed release and then every this many attempts.
15
+ RELEASE_CLAIM_RETRY_WARNING_EVERY = 10
16
+
11
17
  class_methods do
12
18
  # Claims a job by acquiring a session-level advisory lock via the CTE
13
19
  # query, and records the lock acquisition for phantom re-entrancy cleanup.
@@ -20,13 +26,17 @@ module GoodJob
20
26
  def with_advisory_lock_claim(select_limit: nil, &block)
21
27
  raise ArgumentError, "Must provide a block" unless block
22
28
 
23
- advisory_lock(select_limit: select_limit) do |records|
24
- record = records.first
25
- begin
29
+ record = nil
30
+ begin
31
+ advisory_lock(select_limit: select_limit) do |records|
32
+ record = records.first
26
33
  yield(record)
27
- ensure
28
- record&.run_callbacks(:perform_unlocked)
29
34
  end
35
+ ensure
36
+ # Runs after the advisory lock is released. A failed completion leaves the
37
+ # row lock in the database and may leave unsaved completion fields on the
38
+ # record, which batch callbacks would act on.
39
+ record.run_callbacks(:perform_unlocked) if record && (record.destroyed? || record.locked_by_id_in_database.nil?)
30
40
  end
31
41
  end
32
42
 
@@ -40,30 +50,7 @@ module GoodJob
40
50
  # @yield [GoodJob::Job, nil] The claimed record, or nil if none found
41
51
  # @return [Object] The return value of the block
42
52
  def with_skip_locked_claim(locked_by_id:, locked_at:, lock_type:, &block)
43
- raise ArgumentError, "Must provide a block" unless block
44
-
45
- candidate_sql = select(:id).lock("FOR NO KEY UPDATE SKIP LOCKED").to_sql
46
- materialized = supports_cte_materialization_specifiers? ? "MATERIALIZED " : ""
47
-
48
- sql = <<~SQL.squish
49
- WITH candidate AS #{materialized}(#{candidate_sql})
50
- UPDATE #{quoted_table_name}
51
- SET locked_by_id = ?,
52
- locked_at = ?,
53
- lock_type = ?
54
- FROM candidate
55
- WHERE #{quoted_table_name}.id = candidate.id
56
- RETURNING #{quoted_table_name}.*
57
- SQL
58
-
59
- record = unscoped.find_by_sql([sql, locked_by_id, locked_at, lock_types[lock_type.to_s]]).first
60
-
61
- begin
62
- unscoped { yield(record) }
63
- ensure
64
- record.update(locked_by_id: nil, locked_at: nil, lock_type: nil) if record && !record.destroyed? && record.locked_by_id.present?
65
- record&.run_callbacks(:perform_unlocked)
66
- end
53
+ with_row_lock_claim(locked_by_id: locked_by_id, locked_at: locked_at, lock_type: lock_type, &block)
67
54
  end
68
55
 
69
56
  # Atomically claims a job using SELECT FOR NO KEY UPDATE SKIP LOCKED with an
@@ -77,13 +64,21 @@ module GoodJob
77
64
  # @yield [GoodJob::Job, nil] The claimed record, or nil if none found
78
65
  # @return [Object] The return value of the block
79
66
  def with_hybrid_lock_claim(locked_by_id:, locked_at:, lock_type:, &block)
80
- raise ArgumentError, "Must provide a block" unless block
67
+ with_row_lock_claim(locked_by_id: locked_by_id, locked_at: locked_at, lock_type: lock_type, advisory: true, &block)
68
+ end
81
69
 
82
- lease_connection # sticky connection; advisory lock must outlive this statement
70
+ private
83
71
 
72
+ def with_row_lock_claim(locked_by_id:, locked_at:, lock_type:, advisory: false, &block)
73
+ raise ArgumentError, "Must provide a block" unless block
74
+
75
+ connection = lease_connection if advisory # retain the session holding the advisory lock
84
76
  candidate_sql = select(:id).lock("FOR NO KEY UPDATE SKIP LOCKED").to_sql
85
- advisory_lock_expr = "('x' || substr(md5(#{_quoted_table_name_string} || '-' || id::text), 1, 16))::bit(64)::bigint"
86
77
  materialized = supports_cte_materialization_specifiers? ? "MATERIALIZED " : ""
78
+ candidate_filter = if advisory
79
+ advisory_key = advisory_lock_bigint_sql("#{_quoted_table_name_string} || '-' || id::text")
80
+ "WHERE pg_try_advisory_lock(#{advisory_key})"
81
+ end
87
82
 
88
83
  sql = <<~SQL.squish
89
84
  WITH candidate AS #{materialized}(#{candidate_sql})
@@ -91,26 +86,57 @@ module GoodJob
91
86
  SET locked_by_id = ?,
92
87
  locked_at = ?,
93
88
  lock_type = ?
94
- FROM (
95
- SELECT id FROM candidate
96
- WHERE pg_try_advisory_lock(#{advisory_lock_expr})
97
- ) AS locked
89
+ FROM (SELECT id FROM candidate #{candidate_filter}) AS locked
98
90
  WHERE #{quoted_table_name}.id = locked.id
99
91
  RETURNING #{quoted_table_name}.*
100
92
  SQL
101
93
 
102
94
  record = unscoped.find_by_sql([sql, locked_by_id, locked_at, lock_types[lock_type.to_s]]).first
103
- record_advisory_lock(lease_connection, record.lockable_column_key, cte: true) if record
95
+ record_advisory_lock(connection, record.lockable_column_key, cte: true) if advisory && record
104
96
 
97
+ completed = false
105
98
  begin
106
- unscoped { yield(record) }
99
+ result = unscoped { yield(record) }
100
+ completed = true
101
+ result
107
102
  ensure
108
- if record
109
- record.advisory_unlock
110
- record.update(locked_by_id: nil, locked_at: nil, lock_type: nil) if !record.destroyed? && record.locked_by_id.present?
103
+ begin
104
+ record.advisory_unlock(connection: connection) if advisory && record
105
+ ensure
106
+ # A failed completion may have cleared the attributes in memory only.
107
+ if record && (!completed || (!record.destroyed? && record.locked_by_id_in_database.present?))
108
+ released = release_lock_claim(record, locked_by_id: locked_by_id, locked_at: locked_at)
109
+ completed &&= released
110
+ end
111
+ end
112
+ record&.run_callbacks(:perform_unlocked) if completed
113
+ end
114
+ end
115
+
116
+ def release_lock_claim(record, locked_by_id:, locked_at:)
117
+ transaction do
118
+ # Match the original claim, including its time, so a newer execution is untouched.
119
+ released = unscoped.where(id: record.id, locked_by_id: locked_by_id, locked_at: locked_at)
120
+ .update_all(locked_by_id: nil, locked_at: nil, lock_type: nil) # rubocop:disable Rails/SkipsModelValidations
121
+ if released.positive?
122
+ GoodJob::ConcurrencyClaim.release_job(record.id) if GoodJob::ConcurrencyClaim.table_exists?
123
+ record.reload
111
124
  end
112
- record&.run_callbacks(:perform_unlocked)
125
+ released.positive?
113
126
  end
127
+ rescue StandardError => e
128
+ connection_errors = %w[ActiveRecord::ConnectionNotEstablished ActiveRecord::ConnectionFailed PG::ConnectionBad PG::UnableToSend].filter_map(&:safe_constantize)
129
+ cause = e.is_a?(ActiveRecord::StatementInvalid) ? e.cause : nil # older Rails wraps PG connection errors
130
+ raise unless connection_errors.any? { |klass| e.is_a?(klass) || cause.is_a?(klass) }
131
+ # Once the process is exiting, its stale process record lets cleanup release the claim.
132
+ return false if Thread.current.status == "aborting" || GoodJob.current_thread_shutting_down?
133
+
134
+ # Keep the worker registered until its claim can be released. Otherwise a
135
+ # live process can keep the abandoned job locked indefinitely.
136
+ attempts = (attempts || 0) + 1
137
+ GoodJob.logger.warn("GoodJob could not release the claim on job #{record.id} (attempt #{attempts}): #{e.class}: #{e.message}. Retrying every #{RELEASE_CLAIM_RETRY_INTERVAL} seconds.") if attempts == 1 || (attempts % RELEASE_CLAIM_RETRY_WARNING_EVERY).zero?
138
+ sleep RELEASE_CLAIM_RETRY_INTERVAL
139
+ retry
114
140
  end
115
141
  end
116
142
  end
@@ -1,3 +1,4 @@
1
+ # typed: true
1
2
  # frozen_string_literal: true
2
3
 
3
4
  module GoodJob
@@ -72,7 +73,7 @@ module GoodJob
72
73
  has_many :executions, class_name: 'GoodJob::Execution', foreign_key: 'active_job_id', primary_key: "id", inverse_of: :job, dependent: :delete_all
73
74
  has_many :concurrency_claims, class_name: 'GoodJob::ConcurrencyClaim', primary_key: :id, inverse_of: false, dependent: nil
74
75
 
75
- before_create -> { self.id = active_job_id }, if: -> { active_job_id.present? }
76
+ before_create ->(job) { job.id = job.active_job_id }, if: -> { active_job_id.present? }
76
77
 
77
78
  # Get Jobs finished before the given timestamp.
78
79
  # @!method finished_before(timestamp)
@@ -582,7 +583,7 @@ module GoodJob
582
583
  when String
583
584
  klass.constantize
584
585
  when Job
585
- klass.job_class.constantize
586
+ klass.job_class&.constantize
586
587
  else
587
588
  klass
588
589
  end
@@ -685,13 +686,14 @@ module GoodJob
685
686
  active_job.executions = (active_job.executions || 0) + 1
686
687
 
687
688
  begin
688
- error_class, error_message = error.split(ERROR_MESSAGE_SEPARATOR).map(&:strip)
689
- error = error_class.constantize.new(error_message)
689
+ error_class, error_message = error.to_s.split(ERROR_MESSAGE_SEPARATOR).map(&:strip)
690
+ error = error_class ? error_class.constantize.new(error_message) : StandardError.new(error)
690
691
  rescue StandardError
691
692
  error = StandardError.new(error)
692
693
  end
693
694
 
694
- new_active_job = nil
695
+ # Populated by a block that may be deferred until after the transaction commits
696
+ new_active_jobs = []
695
697
 
696
698
  transaction do
697
699
  Job.defer_after_commit_maybe(active_job.class) do
@@ -701,7 +703,7 @@ module GoodJob
701
703
 
702
704
  # NOTE: I18n.with_locale necessary until fixed in rails https://github.com/rails/rails/pull/52121
703
705
  I18n.with_locale(active_job.locale) do
704
- new_active_job = active_job.retry_job(wait: 0, error: error)
706
+ new_active_jobs << active_job.retry_job(wait: 0, error: error)
705
707
  end
706
708
  end
707
709
  end
@@ -709,7 +711,7 @@ module GoodJob
709
711
  save!
710
712
  end
711
713
 
712
- new_active_job
714
+ new_active_jobs.first
713
715
  end
714
716
  end
715
717
  end
@@ -780,8 +782,7 @@ module GoodJob
780
782
 
781
783
  job_performed_at = Time.current
782
784
  monotonic_start = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
783
- execution = nil
784
- result = GoodJob::CurrentThread.within do |current_thread|
785
+ execution, result = GoodJob::CurrentThread.within do |current_thread|
785
786
  current_thread.reset
786
787
  current_thread.job = self
787
788
 
@@ -794,16 +795,16 @@ module GoodJob
794
795
  self.error_event = ErrorEvents::INTERRUPTED
795
796
  monotonic_duration = (::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - monotonic_start).seconds
796
797
 
797
- execution_attrs = {
798
+ interrupted_execution_attrs = {
798
799
  error: interrupt_error_string,
799
800
  finished_at: job_performed_at,
800
801
  error_event: error_event,
801
802
  duration: monotonic_duration,
802
803
  }
803
- executions.where(finished_at: nil).where.not(performed_at: nil).update_all(execution_attrs) # rubocop:disable Rails/SkipsModelValidations
804
+ executions.where(finished_at: nil).where.not(performed_at: nil).update_all(interrupted_execution_attrs) # rubocop:disable Rails/SkipsModelValidations
804
805
  end
805
806
 
806
- transaction do
807
+ new_execution = transaction do
807
808
  execution_attrs = {
808
809
  job_class: job_class,
809
810
  queue_name: queue_name,
@@ -822,11 +823,12 @@ module GoodJob
822
823
  job_attrs[:lock_type] = :advisory if self.class.lock_type_column_exists?
823
824
  end
824
825
 
825
- execution = executions.create!(execution_attrs)
826
+ created_execution = executions.create!(execution_attrs)
826
827
  update!(job_attrs)
828
+ created_execution
827
829
  end
828
830
 
829
- ActiveSupport::Notifications.instrument("perform_job.good_job", { job: self, execution: execution, process_id: current_thread.process_id, thread_name: current_thread.thread_name }) do |instrument_payload|
831
+ execution_result = ActiveSupport::Notifications.instrument("perform_job.good_job", { job: self, execution: new_execution, process_id: current_thread.process_id, thread_name: current_thread.thread_name }) do |instrument_payload|
830
832
  value = ActiveJob::Base.execute(active_job_data)
831
833
  active_job = current_thread.active_job
832
834
 
@@ -872,6 +874,8 @@ module GoodJob
872
874
  )
873
875
  ExecutionResult.new(value: nil, active_job: active_job, unhandled_error: e, error_event: error_event)
874
876
  end
877
+
878
+ [new_execution, execution_result]
875
879
  end
876
880
 
877
881
  job_attributes = { locked_by_id: nil, locked_at: nil }
@@ -1019,8 +1023,8 @@ module GoodJob
1019
1023
  end
1020
1024
 
1021
1025
  def continue_discard_or_finish_batch
1022
- batch._continue_discard_or_finish(self) if batch.present?
1023
- callback_batch._continue_discard_or_finish if callback_batch.present?
1026
+ batch&._continue_discard_or_finish(self)
1027
+ callback_batch&._continue_discard_or_finish
1024
1028
  end
1025
1029
 
1026
1030
  def active_job_data
@@ -1,3 +1,4 @@
1
+ # typed: true
1
2
  # frozen_string_literal: true
2
3
 
3
4
  require 'socket'
@@ -28,7 +29,7 @@ module GoodJob # :nodoc:
28
29
  end
29
30
  when /darwin|bsd/
30
31
  lambda do |pid|
31
- `ps -o pid,rss -p #{pid.to_i}`.lines.last.split.last.to_i
32
+ `ps -o pid,rss -p #{pid.to_i}`.lines.last&.split&.last.to_i
32
33
  end
33
34
  else
34
35
  ->(_pid) { 0 }
@@ -92,12 +93,11 @@ module GoodJob # :nodoc:
92
93
  }
93
94
  attributes[:lock_type] = :advisory if with_advisory_lock
94
95
 
95
- record = nil
96
96
  transaction do
97
97
  record = create!(attributes)
98
98
  record.advisory_lock!(connection: advisory_lock_connection) if with_advisory_lock
99
+ record
99
100
  end
100
- record
101
101
  rescue ActiveRecord::RecordNotUnique
102
102
  find_by(id: id).tap do |existing_record|
103
103
  next unless existing_record
@@ -119,7 +119,7 @@ module GoodJob # :nodoc:
119
119
  proctitle: $PROGRAM_NAME,
120
120
  preserve_job_records: GoodJob.preserve_job_records,
121
121
  retry_on_unhandled_error: GoodJob.retry_on_unhandled_error,
122
- schedulers: GoodJob::Scheduler.instances.map(&:stats),
122
+ schedulers: GoodJob::Scheduler.instances.reject(&:shutdown?).map(&:stats),
123
123
  cron_enabled: GoodJob.configuration.enable_cron?,
124
124
  total_succeeded_executions_count: GoodJob::Scheduler.instances.sum { |scheduler| scheduler.stats.fetch(:succeeded_executions_count) },
125
125
  total_errored_executions_count: GoodJob::Scheduler.instances.sum { |scheduler| scheduler.stats.fetch(:errored_executions_count) },
@@ -136,7 +136,7 @@ module GoodJob # :nodoc:
136
136
  update(state: state, updated_at: Time.current)
137
137
  rescue ActiveRecord::RecordNotFound
138
138
  @new_record = true
139
- self.created_at = self.updated_at = nil
139
+ assign_attributes(created_at: nil, updated_at: nil)
140
140
  id_will_change!
141
141
  state_will_change!
142
142
  save
@@ -1,3 +1,4 @@
1
+ # typed: true
1
2
  # frozen_string_literal: true
2
3
 
3
4
  module GoodJob
@@ -38,7 +39,8 @@ module GoodJob
38
39
  enabled_setting.save!
39
40
 
40
41
  disabled_setting = GoodJob::Setting.find_by(key: CRON_KEYS_DISABLED)
41
- return unless disabled_setting&.value&.include?(key_string)
42
+ return unless disabled_setting
43
+ return unless disabled_setting.value&.include?(key_string)
42
44
 
43
45
  disabled_setting.value.delete(key_string)
44
46
  disabled_setting.save!
@@ -47,7 +49,8 @@ module GoodJob
47
49
  def self.cron_key_disable(key)
48
50
  enabled_setting = GoodJob::Setting.find_by(key: CRON_KEYS_ENABLED)
49
51
  key_string = key.to_s
50
- if enabled_setting&.value&.include?(key_string)
52
+ enabled_keys = enabled_setting&.value
53
+ if enabled_setting && enabled_keys&.include?(key_string)
51
54
  enabled_setting.value.delete(key_string)
52
55
  enabled_setting.save!
53
56
  end
@@ -49,13 +49,13 @@
49
49
  {
50
50
  "warning_type": "Dangerous Eval",
51
51
  "warning_code": 13,
52
- "fingerprint": "f7e62770ef61ea7bb2b3a0a8c19b3fa6f869fbaa51322cb56eb86f21b384ba5a",
52
+ "fingerprint": "7697b92be7ba0f505bd3a1b3aa7b555812687cab2008a005e94b6755e6061362",
53
53
  "check_name": "Evaluation",
54
54
  "message": "Dynamic string evaluated as code",
55
55
  "file": "lib/good_job/log_subscriber.rb",
56
- "line": 365,
56
+ "line": 366,
57
57
  "link": "https://brakemanscanner.org/docs/warning_types/dangerous_eval/",
58
- "code": "class_eval(\" def #{level}(progname = nil, tags: [], &block) # def info(progname = nil, tags: [], &block)\\n return unless logger # return unless logger\\n #\\n tag_logger(*tags) do # tag_logger(*tags) do\\n logger.#{level}(progname, &block) # logger.info(progname, &block)\\n end # end\\n end #\\n\", \"lib/good_job/log_subscriber.rb\", (365 + 1))",
58
+ "code": "class_eval(\" def #{level}(progname = nil, tags: [], &block) # def info(progname = nil, tags: [], &block)\\n return unless logger # return unless logger\\n #\\n tag_logger(*tags) do # tag_logger(*tags) do\\n logger.#{level}(progname, &block) # logger.info(progname, &block)\\n end # end\\n end #\\n\", \"lib/good_job/log_subscriber.rb\", (366 + 1))",
59
59
  "render_path": null,
60
60
  "location": {
61
61
  "type": "method",
@@ -119,25 +119,25 @@
119
119
  {
120
120
  "warning_type": "SQL Injection",
121
121
  "warning_code": 0,
122
- "fingerprint": "e3cc5489d3e3e81f35347b7530d5743fbac8491ab612ceec0d034e22656269ed",
122
+ "fingerprint": "3f2f0feed3442f5946899d5884a014ecb86894269ac9685db90eeeb6f3a06f48",
123
123
  "check_name": "SQL",
124
124
  "message": "Possible SQL injection",
125
125
  "file": "app/models/good_job/job/lockable.rb",
126
- "line": 102,
126
+ "line": 80,
127
127
  "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/",
128
- "code": "unscoped.find_by_sql([\"WITH candidate AS #{(\"MATERIALIZED \" or \"\")}(#{select(:id).lock(\"FOR NO KEY UPDATE SKIP LOCKED\").to_sql})\\nUPDATE #{quoted_table_name}\\nSET locked_by_id = ?,\\n locked_at = ?,\\n lock_type = ?\\nFROM (\\n SELECT id FROM candidate\\n WHERE pg_try_advisory_lock(#{\"('x' || substr(md5(#{_quoted_table_name_string} || '-' || id::text), 1, 16))::bit(64)::bigint\"})\\n) AS locked\\nWHERE #{quoted_table_name}.id = locked.id\\nRETURNING #{quoted_table_name}.*\\n\".squish, locked_by_id, locked_at, lock_types[lock_type.to_s]])",
128
+ "code": "unscoped.find_by_sql([\"WITH candidate AS #{(\"MATERIALIZED \" or \"\")}(#{select(:id).lock(\"FOR NO KEY UPDATE SKIP LOCKED\").to_sql})\\nUPDATE #{quoted_table_name}\\nSET locked_by_id = ?,\\n locked_at = ?,\\n lock_type = ?\\nFROM (SELECT id FROM candidate #{if advisory then\n advisory_key = advisory_lock_bigint_sql(\"#{_quoted_table_name_string} || '-' || id::text\")\n \"WHERE pg_try_advisory_lock(#{advisory_lock_bigint_sql(\"#{_quoted_table_name_string} || '-' || id::text\")})\"\nend}) AS locked\\nWHERE #{quoted_table_name}.id = locked.id\\nRETURNING #{quoted_table_name}.*\\n\".squish, locked_by_id, locked_at, lock_types[lock_type.to_s]])",
129
129
  "render_path": null,
130
130
  "location": {
131
131
  "type": "method",
132
132
  "class": "GoodJob::Job",
133
- "method": "with_hybrid_lock_claim"
133
+ "method": "with_row_lock_claim"
134
134
  },
135
- "user_input": "_quoted_table_name_string",
135
+ "user_input": "if advisory then\n advisory_key = advisory_lock_bigint_sql(\"#{_quoted_table_name_string} || '-' || id::text\")\n \"WHERE pg_try_advisory_lock(#{advisory_lock_bigint_sql(\"#{_quoted_table_name_string} || '-' || id::text\")})\"\nend",
136
136
  "confidence": "Weak",
137
137
  "cwe_id": [
138
138
  89
139
139
  ],
140
- "note": "Table name is quoted via quoted_table_name (Rails); advisory lock expression uses _quoted_table_name_string (escaped); candidate SQL is Rails-generated; values are parameterized bindings."
140
+ "note": "Claim query refactored into with_row_lock_claim. Table names are Rails-quoted; candidate SQL is Rails-generated; the optional advisory predicate uses only the configured hash and an escaped table name. All varying claim values are bound. No untrusted SQL input."
141
141
  }
142
142
  ],
143
143
  "brakeman_version": "8.0.4"
@@ -1,3 +1,4 @@
1
+ # typed: true
1
2
  # frozen_string_literal: true
2
3
 
3
4
  module ActiveJob # :nodoc:
@@ -1,3 +1,4 @@
1
+ # typed: true
1
2
  # frozen_string_literal: true
2
3
 
3
4
  require 'rails/generators'
@@ -1,3 +1,4 @@
1
+ # typed: true
1
2
  # frozen_string_literal: true
2
3
 
3
4
  require 'rails/generators'
@@ -1,3 +1,4 @@
1
+ # typed: false
1
2
  # frozen_string_literal: true
2
3
 
3
4
  module GoodJob
@@ -1,3 +1,4 @@
1
+ # typed: false
1
2
  # frozen_string_literal: true
2
3
 
3
4
  module GoodJob
@@ -1,3 +1,4 @@
1
+ # typed: false
1
2
  # frozen_string_literal: true
2
3
 
3
4
  module GoodJob
@@ -1,3 +1,4 @@
1
+ # typed: false
1
2
  # frozen_string_literal: true
2
3
 
3
4
  module GoodJob
@@ -1,3 +1,4 @@
1
+ # typed: false
1
2
  # frozen_string_literal: true
2
3
 
3
4
  module GoodJob
@@ -1,3 +1,4 @@
1
+ # typed: true
1
2
  # frozen_string_literal: true
2
3
 
3
4
  require 'active_support/core_ext/module/attribute_accessors_per_thread'
@@ -1,3 +1,4 @@
1
+ # typed: true
1
2
  # frozen_string_literal: true
2
3
 
3
4
  module GoodJob
@@ -67,7 +68,6 @@ module GoodJob
67
68
  end
68
69
  end
69
70
 
70
- inline_jobs = []
71
71
  lock_strategy = GoodJob::Job.effective_lock_strategy
72
72
  tracker_registered = false
73
73
  lock_id = nil
@@ -78,7 +78,7 @@ module GoodJob
78
78
  end
79
79
 
80
80
  begin
81
- GoodJob::Job.transaction(requires_new: true, joinable: false) do
81
+ inline_jobs = GoodJob::Job.transaction(requires_new: true, joinable: false) do
82
82
  results = if GoodJob::Ractors.main?
83
83
  column_names = GoodJob::Job.column_names
84
84
  job_attributes = jobs.map { |job| job.attributes.slice(*column_names) }
@@ -96,20 +96,20 @@ module GoodJob
96
96
  job.instance_variable_set(:@new_record, false) if job_id_to_provider_job_id[job.active_job_id]
97
97
  end
98
98
  jobs = jobs.select(&:persisted?) # prune unpersisted jobs
99
-
100
- if execute_inline?
101
- inline_jobs = jobs.select { |job| job.scheduled_at.nil? || job.scheduled_at <= Time.current }
102
- if lock_strategy != :advisory && lock_id && inline_jobs.any?
103
- GoodJob::Job.where(id: inline_jobs.map(&:id)).update_all( # rubocop:disable Rails/SkipsModelValidations
104
- locked_by_id: lock_id, locked_at: current_time, lock_type: GoodJob::Job.lock_types[lock_strategy.to_s]
105
- )
106
- inline_jobs.each { |j| j.assign_attributes(locked_by_id: lock_id, locked_at: current_time, lock_type: lock_strategy) }
107
- end
108
- case lock_strategy
109
- when :advisory, :hybrid
110
- inline_jobs.each(&:advisory_lock!)
111
- end
99
+ next [] unless execute_inline?
100
+
101
+ jobs_to_inline = jobs.select { |job| job.scheduled_at.nil? || job.scheduled_at <= Time.current }
102
+ if lock_strategy != :advisory && lock_id && jobs_to_inline.any?
103
+ GoodJob::Job.where(id: jobs_to_inline.map(&:id)).update_all( # rubocop:disable Rails/SkipsModelValidations
104
+ locked_by_id: lock_id, locked_at: current_time, lock_type: GoodJob::Job.lock_types[lock_strategy.to_s]
105
+ )
106
+ jobs_to_inline.each { |j| j.assign_attributes(locked_by_id: lock_id, locked_at: current_time, lock_type: lock_strategy) }
107
+ end
108
+ case lock_strategy
109
+ when :advisory, :hybrid
110
+ jobs_to_inline.each(&:advisory_lock!)
112
111
  end
112
+ jobs_to_inline
113
113
  end
114
114
 
115
115
  if inline_jobs.any?
@@ -150,7 +150,7 @@ module GoodJob
150
150
 
151
151
  executed_locally = execute_async? && create_capsule_thread(state)
152
152
  unless executed_locally
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
+ state[:count] = jobs_by_queue_and_scheduled_at.count { |job| send_notify?(job_id_to_active_jobs[job.active_job_id]) }
154
154
  Notifier.notify(state) unless state[:count].zero?
155
155
  end
156
156
  end
@@ -185,7 +185,8 @@ module GoodJob
185
185
  elsif will_execute_inline
186
186
  lock_strategy = GoodJob::Job.effective_lock_strategy
187
187
  lock_id = register_tracker
188
- tracker_registered = true
188
+ # Unregistered by whichever of the deferred block or the rescue runs first
189
+ tracker_registered = Concurrent::AtomicBoolean.new(true)
189
190
  begin
190
191
  job = case lock_strategy
191
192
  when :skiplocked
@@ -198,14 +199,10 @@ module GoodJob
198
199
  InlineBuffer.perform_now_or_defer do
199
200
  perform_inline(job, notify: send_notify?(active_job), already_claimed: lock_strategy != :advisory, advisory_unlock: lock_strategy != :skiplocked)
200
201
  ensure
201
- unregister_tracker if tracker_registered
202
- tracker_registered = false
202
+ unregister_tracker if tracker_registered.make_false
203
203
  end
204
204
  rescue StandardError
205
- if tracker_registered
206
- unregister_tracker
207
- tracker_registered = false
208
- end
205
+ unregister_tracker if tracker_registered.make_false
209
206
  raise
210
207
  end
211
208
  else
@@ -348,16 +345,12 @@ module GoodJob
348
345
  # @param job [GoodJob::Job] the job to perform, which must be enqueued and advisory locked already
349
346
  # @param notify [Boolean] whether to send a NOTIFY event for a retried job
350
347
  def perform_inline(job, notify: true, already_claimed: false, advisory_unlock: true)
351
- result = nil
352
- retried_job = nil
353
-
354
- loop do
355
- result = job.perform(lock_id: tracker_id_for_lock, already_claimed: already_claimed)
356
- already_claimed = false # only skip lock-write on first iteration
357
- retried_job = result.retried_job
358
- break if retried_job.nil? || retried_job.scheduled_at.nil? || retried_job.scheduled_at > Time.current
359
-
348
+ result = job.perform(lock_id: tracker_id_for_lock, already_claimed: already_claimed)
349
+ retried_job = result.retried_job
350
+ while retried_job&.scheduled_at && retried_job.scheduled_at <= Time.current
360
351
  job = retried_job
352
+ result = job.perform(lock_id: tracker_id_for_lock) # only skip lock-write on first iteration
353
+ retried_job = result.retried_job
361
354
  end
362
355
 
363
356
  Notifier.notify(retried_job.job_state) if notify && retried_job&.scheduled_at && retried_job.scheduled_at > Time.current
data/lib/good_job/bulk.rb CHANGED
@@ -1,3 +1,4 @@
1
+ # typed: true
1
2
  # frozen_string_literal: true
2
3
 
3
4
  require 'active_support/core_ext/module/attribute_accessors_per_thread'
@@ -1,3 +1,4 @@
1
+ # typed: true
1
2
  # frozen_string_literal: true
2
3
 
3
4
  module GoodJob
@@ -1,3 +1,4 @@
1
+ # typed: true
1
2
  # frozen_string_literal: true
2
3
 
3
4
  module GoodJob
@@ -69,7 +70,10 @@ module GoodJob
69
70
  def restart(timeout: NONE)
70
71
  raise ArgumentError, "Capsule#restart cannot be called with a timeout of nil" if timeout.nil?
71
72
 
73
+ replaced_instances = [@notifier, @poller, @cron_manager, *@multi_scheduler&.schedulers].compact
72
74
  shutdown(timeout: timeout)
75
+ # Start creates new components, so unregister the replaced ones to let them be garbage collected
76
+ replaced_instances.each { |instance| instance.class.prune(instance) }
73
77
  start(force: true)
74
78
  end
75
79
 
@@ -1,5 +1,7 @@
1
+ # typed: true
1
2
  # frozen_string_literal: true
2
3
 
4
+ require "concurrent/atomic/atomic_boolean"
3
5
  require "concurrent/errors"
4
6
  require "concurrent/scheduled_task"
5
7
 
@@ -40,7 +42,6 @@ module GoodJob # :nodoc:
40
42
  # If UUID has not yet been persisted to the database, this method will make a query to insert or update it.
41
43
  # @return [String, nil]
42
44
  def id_for_lock
43
- value = nil
44
45
  synchronize(with_connection: true) do
45
46
  next if @locks.zero?
46
47
 
@@ -50,9 +51,8 @@ module GoodJob # :nodoc:
50
51
  @record = GoodJob::Process.find_or_create_record(id: @record_id)
51
52
  create_refresh_task
52
53
  end
53
- value = @record&.id
54
+ @record&.id
54
55
  end
55
- value
56
56
  end
57
57
 
58
58
  # Whether the tracker has no active registrations and can be removed from {.instances}.
@@ -188,15 +188,15 @@ module GoodJob # :nodoc:
188
188
  delay ||= task_interval
189
189
  refresh_task = Concurrent::ScheduledTask.new(delay.to_f, executor: @executor) do
190
190
  Rails.application.executor.wrap do
191
- acquired = false
191
+ acquired = Concurrent::AtomicBoolean.new(false)
192
192
  begin
193
193
  # Match notifier/job lock ordering: acquire a connection before the mutex.
194
194
  GoodJob::Process.connection_pool.with_connection do
195
- acquired = true
195
+ acquired.make_true
196
196
  synchronize { renew(silent: true) if @locks.positive? }
197
197
  end
198
198
  rescue ActiveRecord::ConnectionTimeoutError
199
- raise if acquired
199
+ raise if acquired.true?
200
200
  ensure
201
201
  synchronize do
202
202
  if @refresh_task.equal?(refresh_task)
@@ -1,3 +1,4 @@
1
+ # typed: true
1
2
  # frozen_string_literal: true
2
3
 
3
4
  module GoodJob # :nodoc: