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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +33 -0
- data/README.md +7 -1
- data/app/charts/good_job/base_chart.rb +1 -0
- data/app/charts/good_job/latency_metric/base.rb +2 -1
- data/app/charts/good_job/latency_metric/execution.rb +1 -0
- data/app/charts/good_job/latency_metric/queue.rb +1 -0
- data/app/charts/good_job/latency_metric/total.rb +1 -0
- data/app/charts/good_job/latency_metric.rb +1 -0
- data/app/charts/good_job/performance_index_chart.rb +1 -0
- data/app/charts/good_job/performance_range.rb +5 -3
- data/app/charts/good_job/performance_show_chart.rb +1 -0
- data/app/charts/good_job/scheduled_by_queue_chart.rb +1 -0
- data/app/controllers/good_job/application_controller.rb +7 -4
- data/app/controllers/good_job/batches_controller.rb +1 -0
- data/app/controllers/good_job/cleaner_controller.rb +1 -0
- data/app/controllers/good_job/cron_entries_controller.rb +1 -0
- data/app/controllers/good_job/executions_controller.rb +1 -0
- data/app/controllers/good_job/frontends_controller.rb +9 -4
- data/app/controllers/good_job/jobs_controller.rb +1 -0
- data/app/controllers/good_job/metrics_controller.rb +1 -0
- data/app/controllers/good_job/pauses_controller.rb +1 -0
- data/app/controllers/good_job/performance_controller.rb +1 -0
- data/app/controllers/good_job/processes_controller.rb +1 -0
- data/app/filters/good_job/base_filter.rb +10 -1
- data/app/filters/good_job/batches_filter.rb +1 -0
- data/app/filters/good_job/jobs_filter.rb +4 -1
- data/app/helpers/good_job/application_helper.rb +1 -0
- data/app/helpers/good_job/icons_helper.rb +1 -0
- data/app/models/concerns/good_job/advisory_lockable.rb +23 -5
- data/app/models/concerns/good_job/error_events.rb +1 -0
- data/app/models/concerns/good_job/filterable.rb +1 -0
- data/app/models/concerns/good_job/filtered_arguments.rb +1 -0
- data/app/models/concerns/good_job/reportable.rb +1 -0
- data/app/models/good_job/active_record_parent_class.rb +1 -0
- data/app/models/good_job/base_record.rb +1 -0
- data/app/models/good_job/batch.rb +17 -16
- data/app/models/good_job/batch_record.rb +10 -8
- data/app/models/good_job/concurrency_claim.rb +1 -0
- data/app/models/good_job/cron_entry.rb +1 -0
- data/app/models/good_job/discrete_execution.rb +1 -0
- data/app/models/good_job/execution.rb +6 -3
- data/app/models/good_job/execution_result.rb +1 -0
- data/app/models/good_job/i18n_config.rb +1 -0
- data/app/models/good_job/job/lockable.rb +68 -42
- data/app/models/good_job/job.rb +20 -16
- data/app/models/good_job/process.rb +5 -5
- data/app/models/good_job/setting.rb +5 -2
- data/config/brakeman.ignore +9 -9
- data/lib/active_job/queue_adapters/good_job_adapter.rb +1 -0
- data/lib/generators/good_job/install_generator.rb +1 -0
- data/lib/generators/good_job/update_generator.rb +1 -0
- data/lib/good_job/active_job_extensions/batches.rb +1 -0
- data/lib/good_job/active_job_extensions/concurrency.rb +1 -0
- data/lib/good_job/active_job_extensions/interrupt_errors.rb +1 -0
- data/lib/good_job/active_job_extensions/labels.rb +1 -0
- data/lib/good_job/active_job_extensions/notify_options.rb +1 -0
- data/lib/good_job/adapter/inline_buffer.rb +1 -0
- data/lib/good_job/adapter.rb +25 -32
- data/lib/good_job/bulk.rb +1 -0
- data/lib/good_job/callable.rb +1 -0
- data/lib/good_job/capsule.rb +4 -0
- data/lib/good_job/capsule_tracker.rb +6 -6
- data/lib/good_job/cleanup_tracker.rb +1 -0
- data/lib/good_job/cli.rb +5 -0
- data/lib/good_job/configuration/validator.rb +1 -0
- data/lib/good_job/configuration.rb +13 -0
- data/lib/good_job/cron_manager.rb +1 -0
- data/lib/good_job/current_thread.rb +2 -1
- data/lib/good_job/daemon.rb +1 -0
- data/lib/good_job/dependencies.rb +1 -0
- data/lib/good_job/engine.rb +3 -1
- data/lib/good_job/instance_registry.rb +5 -2
- data/lib/good_job/interrupt_error.rb +1 -0
- data/lib/good_job/interrupted_error.rb +1 -0
- data/lib/good_job/job_performer/metrics.rb +1 -0
- data/lib/good_job/job_performer.rb +5 -4
- data/lib/good_job/lifecycle_hooks.rb +1 -0
- data/lib/good_job/log_subscriber.rb +1 -0
- data/lib/good_job/multi_scheduler.rb +1 -0
- data/lib/good_job/notifier/process_heartbeat.rb +1 -0
- data/lib/good_job/notifier.rb +1 -0
- data/lib/good_job/poller.rb +1 -0
- data/lib/good_job/probe_server/cluster_healthcheck_middleware.rb +1 -0
- data/lib/good_job/probe_server/healthcheck_middleware.rb +1 -0
- data/lib/good_job/probe_server/not_found_app.rb +1 -0
- data/lib/good_job/probe_server/simple_handler.rb +48 -11
- data/lib/good_job/probe_server/webrick_handler.rb +1 -0
- data/lib/good_job/probe_server.rb +1 -0
- data/lib/good_job/ractors.rb +1 -0
- data/lib/good_job/safe_state.rb +1 -0
- data/lib/good_job/scheduler.rb +1 -0
- data/lib/good_job/sd_notify.rb +1 -0
- data/lib/good_job/shared_executor.rb +1 -0
- data/lib/good_job/subprocess.rb +1 -0
- data/lib/good_job/supervisor.rb +22 -15
- data/lib/good_job/systemd_service.rb +1 -0
- data/lib/good_job/thread_status.rb +1 -0
- data/lib/good_job/version.rb +2 -1
- data/lib/good_job.rb +47 -21
- 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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
109
|
-
record.advisory_unlock
|
|
110
|
-
|
|
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
|
-
|
|
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
|
data/app/models/good_job/job.rb
CHANGED
|
@@ -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 -> {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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:
|
|
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
|
|
1023
|
-
callback_batch
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
data/config/brakeman.ignore
CHANGED
|
@@ -49,13 +49,13 @@
|
|
|
49
49
|
{
|
|
50
50
|
"warning_type": "Dangerous Eval",
|
|
51
51
|
"warning_code": 13,
|
|
52
|
-
"fingerprint": "
|
|
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":
|
|
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\", (
|
|
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": "
|
|
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":
|
|
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 (
|
|
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": "
|
|
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
|
|
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"
|
data/lib/good_job/adapter.rb
CHANGED
|
@@ -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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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] =
|
|
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
|
-
|
|
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 =
|
|
352
|
-
retried_job =
|
|
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
data/lib/good_job/callable.rb
CHANGED
data/lib/good_job/capsule.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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)
|