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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 50f42b2481a308af99413d1a89c0b7d3d4c2e07cc48cc29a323c3c42a528e610
4
- data.tar.gz: a55f5f59b5758614fa38c79b603d4f6298b6e79de40931e05fc4cc4fa0e68f56
3
+ metadata.gz: 62018fa8fb4313f6aa3e6ef7531ac3073adc80449fd28a03f655014ce0c1922b
4
+ data.tar.gz: 478315a41ea1d69a9d15781b4b57ecd099504c3da754605b12fd752a10cb3ad0
5
5
  SHA512:
6
- metadata.gz: adacf056f5e11131e0bb704e5b830a11a4a64b0d0a06339c0edaa13b1d8c94bf8b7e734e0df79d3ccf0230e7ceba3da4f220d66aa4eece5dd4dd5e961af294e1
7
- data.tar.gz: 7b954a6a98b208038575c4d05e6f6e18d219125daf42f66ca8d1e0223f5e18eb1250fd3c608eddeb4f8b78d94ba84f3f141805ccf24f81dedeec0a37bac808ff
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
- advisory_lock(select_limit: select_limit) do |records|
24
- record = records.first
25
- begin
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
- 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
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
- raise ArgumentError, "Must provide a block" unless block
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
- lease_connection # sticky connection; advisory lock must outlive this statement
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(lease_connection, record.lockable_column_key, cte: true) if record
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
- 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?
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
- record&.run_callbacks(:perform_unlocked)
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
@@ -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"
@@ -2,7 +2,7 @@
2
2
 
3
3
  module GoodJob
4
4
  # GoodJob gem version.
5
- VERSION = '4.23.0'
5
+ VERSION = '4.23.1'
6
6
 
7
7
  # GoodJob version as Gem::Version object
8
8
  GEM_VERSION = Gem::Version.new(VERSION)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: good_job
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.23.0
4
+ version: 4.23.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ben Sheldon