good_job 4.23.0 → 4.23.1
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 +8 -0
- data/README.md +2 -0
- data/app/models/good_job/job/lockable.rb +67 -42
- data/config/brakeman.ignore +6 -6
- data/lib/good_job/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 62018fa8fb4313f6aa3e6ef7531ac3073adc80449fd28a03f655014ce0c1922b
|
|
4
|
+
data.tar.gz: 478315a41ea1d69a9d15781b4b57ecd099504c3da754605b12fd752a10cb3ad0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2f3f11b6633b90116e2cbe15057032cdd49609c074a80c21ef496ec250dea79f9ce5f3b3eda27db5c36bfd4cd1c262fda7b41301a168772148d70d35ca3f7dc7
|
|
7
|
+
data.tar.gz: 33ef016c46794b39cd7006b16073849c76d62116ab2c42030087b91286ebfd20887bf8735c7f0183fcd8a5acb4c599ae7eabbddcb36420f3a2484044301fc231
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [v4.23.1](https://github.com/bensheldon/good_job/tree/v4.23.1) (2026-10-27)
|
|
4
|
+
|
|
5
|
+
[Full Changelog](https://github.com/bensheldon/good_job/compare/v4.23.0...v4.23.1)
|
|
6
|
+
|
|
7
|
+
**Fixed bugs:**
|
|
8
|
+
|
|
9
|
+
- Release abandoned job claims after database failures [\#1887](https://github.com/bensheldon/good_job/pull/1887) ([ollym](https://github.com/ollym))
|
|
10
|
+
|
|
3
11
|
## [v4.23.0](https://github.com/bensheldon/good_job/tree/v4.23.0) (2026-10-27)
|
|
4
12
|
|
|
5
13
|
[Full Changelog](https://github.com/bensheldon/good_job/compare/v4.22.1...v4.23.0)
|
data/README.md
CHANGED
|
@@ -1225,6 +1225,8 @@ Note that when running jobs in `:inline` execution mode, `GoodJob.current_thread
|
|
|
1225
1225
|
|
|
1226
1226
|
Jobs will be automatically retried if the process is interrupted while performing a job and the job is unable to finish before the timeout or as the result of a `SIGKILL` or power failure. The interrupted execution's error record will show `GoodJob::InterruptedError` to distinguish it from the rescuable `GoodJob::InterruptError` that is raised when the job is retried.
|
|
1227
1227
|
|
|
1228
|
+
With `:skiplocked` or `:hybrid` locking, a worker releases its claim even if recording the job's completion fails. If the database connection is unavailable during cleanup, the worker waits and retries every five seconds until it can release the claim, GoodJob begins shutting down, or the worker is forcibly terminated. If the worker stops retrying, the claim is released when the exited process's record is cleaned up. The job can then be recovered as an interrupted execution, which may run it again; external effects should be idempotent.
|
|
1229
|
+
|
|
1228
1230
|
If you need more control over interrupt-caused retries, include the `GoodJob::ActiveJobExtensions::InterruptErrors` extension in your job class. When an interrupted job is retried, the extension will raise a `GoodJob::InterruptError` exception within the job, which allows you to use Active Job's `retry_on` and `discard_on` to control the behavior of the job.
|
|
1229
1231
|
|
|
1230
1232
|
```ruby
|
|
@@ -8,6 +8,11 @@ module GoodJob
|
|
|
8
8
|
module Lockable
|
|
9
9
|
extend ActiveSupport::Concern
|
|
10
10
|
|
|
11
|
+
# Seconds to wait between attempts to release a claim after a connection failure.
|
|
12
|
+
RELEASE_CLAIM_RETRY_INTERVAL = 5
|
|
13
|
+
# Log a warning on the first failed release and then every this many attempts.
|
|
14
|
+
RELEASE_CLAIM_RETRY_WARNING_EVERY = 10
|
|
15
|
+
|
|
11
16
|
class_methods do
|
|
12
17
|
# Claims a job by acquiring a session-level advisory lock via the CTE
|
|
13
18
|
# query, and records the lock acquisition for phantom re-entrancy cleanup.
|
|
@@ -20,13 +25,17 @@ module GoodJob
|
|
|
20
25
|
def with_advisory_lock_claim(select_limit: nil, &block)
|
|
21
26
|
raise ArgumentError, "Must provide a block" unless block
|
|
22
27
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
28
|
+
record = nil
|
|
29
|
+
begin
|
|
30
|
+
advisory_lock(select_limit: select_limit) do |records|
|
|
31
|
+
record = records.first
|
|
26
32
|
yield(record)
|
|
27
|
-
ensure
|
|
28
|
-
record&.run_callbacks(:perform_unlocked)
|
|
29
33
|
end
|
|
34
|
+
ensure
|
|
35
|
+
# Runs after the advisory lock is released. A failed completion leaves the
|
|
36
|
+
# row lock in the database and may leave unsaved completion fields on the
|
|
37
|
+
# record, which batch callbacks would act on.
|
|
38
|
+
record.run_callbacks(:perform_unlocked) if record && (record.destroyed? || record.locked_by_id_in_database.nil?)
|
|
30
39
|
end
|
|
31
40
|
end
|
|
32
41
|
|
|
@@ -40,30 +49,7 @@ module GoodJob
|
|
|
40
49
|
# @yield [GoodJob::Job, nil] The claimed record, or nil if none found
|
|
41
50
|
# @return [Object] The return value of the block
|
|
42
51
|
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
|
|
52
|
+
with_row_lock_claim(locked_by_id: locked_by_id, locked_at: locked_at, lock_type: lock_type, &block)
|
|
67
53
|
end
|
|
68
54
|
|
|
69
55
|
# Atomically claims a job using SELECT FOR NO KEY UPDATE SKIP LOCKED with an
|
|
@@ -77,13 +63,21 @@ module GoodJob
|
|
|
77
63
|
# @yield [GoodJob::Job, nil] The claimed record, or nil if none found
|
|
78
64
|
# @return [Object] The return value of the block
|
|
79
65
|
def with_hybrid_lock_claim(locked_by_id:, locked_at:, lock_type:, &block)
|
|
80
|
-
|
|
66
|
+
with_row_lock_claim(locked_by_id: locked_by_id, locked_at: locked_at, lock_type: lock_type, advisory: true, &block)
|
|
67
|
+
end
|
|
81
68
|
|
|
82
|
-
|
|
69
|
+
private
|
|
83
70
|
|
|
71
|
+
def with_row_lock_claim(locked_by_id:, locked_at:, lock_type:, advisory: false, &block)
|
|
72
|
+
raise ArgumentError, "Must provide a block" unless block
|
|
73
|
+
|
|
74
|
+
connection = lease_connection if advisory # retain the session holding the advisory lock
|
|
84
75
|
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
76
|
materialized = supports_cte_materialization_specifiers? ? "MATERIALIZED " : ""
|
|
77
|
+
candidate_filter = if advisory
|
|
78
|
+
advisory_key = advisory_lock_bigint_sql("#{_quoted_table_name_string} || '-' || id::text")
|
|
79
|
+
"WHERE pg_try_advisory_lock(#{advisory_key})"
|
|
80
|
+
end
|
|
87
81
|
|
|
88
82
|
sql = <<~SQL.squish
|
|
89
83
|
WITH candidate AS #{materialized}(#{candidate_sql})
|
|
@@ -91,26 +85,57 @@ module GoodJob
|
|
|
91
85
|
SET locked_by_id = ?,
|
|
92
86
|
locked_at = ?,
|
|
93
87
|
lock_type = ?
|
|
94
|
-
FROM (
|
|
95
|
-
SELECT id FROM candidate
|
|
96
|
-
WHERE pg_try_advisory_lock(#{advisory_lock_expr})
|
|
97
|
-
) AS locked
|
|
88
|
+
FROM (SELECT id FROM candidate #{candidate_filter}) AS locked
|
|
98
89
|
WHERE #{quoted_table_name}.id = locked.id
|
|
99
90
|
RETURNING #{quoted_table_name}.*
|
|
100
91
|
SQL
|
|
101
92
|
|
|
102
93
|
record = unscoped.find_by_sql([sql, locked_by_id, locked_at, lock_types[lock_type.to_s]]).first
|
|
103
|
-
record_advisory_lock(
|
|
94
|
+
record_advisory_lock(connection, record.lockable_column_key, cte: true) if advisory && record
|
|
104
95
|
|
|
96
|
+
completed = false
|
|
105
97
|
begin
|
|
106
|
-
unscoped { yield(record) }
|
|
98
|
+
result = unscoped { yield(record) }
|
|
99
|
+
completed = true
|
|
100
|
+
result
|
|
107
101
|
ensure
|
|
108
|
-
|
|
109
|
-
record.advisory_unlock
|
|
110
|
-
|
|
102
|
+
begin
|
|
103
|
+
record.advisory_unlock(connection: connection) if advisory && record
|
|
104
|
+
ensure
|
|
105
|
+
# A failed completion may have cleared the attributes in memory only.
|
|
106
|
+
if record && (!completed || (!record.destroyed? && record.locked_by_id_in_database.present?))
|
|
107
|
+
released = release_lock_claim(record, locked_by_id: locked_by_id, locked_at: locked_at)
|
|
108
|
+
completed &&= released
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
record&.run_callbacks(:perform_unlocked) if completed
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def release_lock_claim(record, locked_by_id:, locked_at:)
|
|
116
|
+
transaction do
|
|
117
|
+
# Match the original claim, including its time, so a newer execution is untouched.
|
|
118
|
+
released = unscoped.where(id: record.id, locked_by_id: locked_by_id, locked_at: locked_at)
|
|
119
|
+
.update_all(locked_by_id: nil, locked_at: nil, lock_type: nil) # rubocop:disable Rails/SkipsModelValidations
|
|
120
|
+
if released.positive?
|
|
121
|
+
GoodJob::ConcurrencyClaim.release_job(record.id) if GoodJob::ConcurrencyClaim.table_exists?
|
|
122
|
+
record.reload
|
|
111
123
|
end
|
|
112
|
-
|
|
124
|
+
released.positive?
|
|
113
125
|
end
|
|
126
|
+
rescue StandardError => e
|
|
127
|
+
connection_errors = %w[ActiveRecord::ConnectionNotEstablished ActiveRecord::ConnectionFailed PG::ConnectionBad PG::UnableToSend].filter_map(&:safe_constantize)
|
|
128
|
+
cause = e.is_a?(ActiveRecord::StatementInvalid) ? e.cause : nil # older Rails wraps PG connection errors
|
|
129
|
+
raise unless connection_errors.any? { |klass| e.is_a?(klass) || cause.is_a?(klass) }
|
|
130
|
+
# Once the process is exiting, its stale process record lets cleanup release the claim.
|
|
131
|
+
return false if Thread.current.status == "aborting" || GoodJob.current_thread_shutting_down?
|
|
132
|
+
|
|
133
|
+
# Keep the worker registered until its claim can be released. Otherwise a
|
|
134
|
+
# live process can keep the abandoned job locked indefinitely.
|
|
135
|
+
attempts = (attempts || 0) + 1
|
|
136
|
+
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?
|
|
137
|
+
sleep RELEASE_CLAIM_RETRY_INTERVAL
|
|
138
|
+
retry
|
|
114
139
|
end
|
|
115
140
|
end
|
|
116
141
|
end
|
data/config/brakeman.ignore
CHANGED
|
@@ -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/version.rb
CHANGED