good_job 4.22.0 → 4.22.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: c3707d55ab1de17d450f3b52e30739b25350ed229e7fad992ab9f56aa01c88fc
4
- data.tar.gz: 1af86ced85ba5487a1b3c13eac8e4f2988c6685153fee444fd34afa62533ef7b
3
+ metadata.gz: 9ce4bd5cfbb7e4fcc44f90099bc1aa892ef59e1294c69eac5d4279c3968db9c0
4
+ data.tar.gz: '093ba2cc69d8d51f38bbf4cd93d9bf97aa65d995768f00a6b0d27dbe1aacdd08'
5
5
  SHA512:
6
- metadata.gz: 56311442c755f60a717bc23c42f193f3bfc15f31254527320e9183cc681cc43c9a87d89fd5e9c2efe305d14f87f6c3cafa0d9a2e5443c5cad3eafa7da31f5674
7
- data.tar.gz: '0875bd2051120c9cd80d11e781713b2c13a6a05d2fdfa38f54dfdb0c3a616b3cbe4d23769db811211feaf7feb49cb1d2a1bf4e72baa31a9127f10b82ce18f5fd'
6
+ metadata.gz: bcbcad67274883a3cb3d4405d7d43015c5c154241dab9c1d136ccbcae7ea53bf9e6606d4713761b7c565826487f042c0e869ecc14eafacfa46ce3bfb9de6dc26
7
+ data.tar.gz: 56b86db61ba722ebbe770f0f0be2f20eaea6a132af9a3d9e5f688a2c2138d3782d92a3a425ff47b0e1881539c7bfe8135c18444b887665105edc385d3f3aa3a7
data/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## [v4.22.1](https://github.com/bensheldon/good_job/tree/v4.22.1) (2026-10-13)
4
+
5
+ [Full Changelog](https://github.com/bensheldon/good_job/compare/v4.22.0...v4.22.1)
6
+
7
+ **Implemented enhancements:**
8
+
9
+ - Show full job labels under the job name in dashboard tables [\#1862](https://github.com/bensheldon/good_job/pull/1862) ([olivier-thatch](https://github.com/olivier-thatch))
10
+
11
+ **Fixed bugs:**
12
+
13
+ - Keep the process id when refresh recreates a deleted process record [\#1866](https://github.com/bensheldon/good_job/pull/1866) ([wivern-co-uk](https://github.com/wivern-co-uk))
14
+ - Don't apply a concurrency lock when a rule's label resolves to blank [\#1864](https://github.com/bensheldon/good_job/pull/1864) ([bensheldon](https://github.com/bensheldon))
15
+
16
+ **Merged pull requests:**
17
+
18
+ - Support enqueuing jobs from non-main Ractors [\#1863](https://github.com/bensheldon/good_job/pull/1863) ([bensheldon](https://github.com/bensheldon))
19
+
3
20
  ## [v4.22.0](https://github.com/bensheldon/good_job/tree/v4.22.0) (2026-10-09)
4
21
 
5
22
  [Full Changelog](https://github.com/bensheldon/good_job/compare/v4.21.1...v4.22.0)
@@ -186,3 +186,8 @@
186
186
  border-top-left-radius: 0 !important;
187
187
  border-bottom-left-radius: 0 !important;
188
188
  }
189
+
190
+ /* Stop a long job name from widening its cell, so label badges clip at the column edge. */
191
+ .min-width-0 {
192
+ min-width: 0;
193
+ }
@@ -27,14 +27,15 @@ module GoodJob
27
27
  # Renders a job's label as a badge. Labels on which the job has a concurrency claim
28
28
  # are colored and prefixed with an icon for the claim's state.
29
29
  # Claims are only shown when the job's +concurrency_claims+ are already loaded.
30
- def job_label_badge(job, label, url: nil)
30
+ # With +full: true+, the badge shows the full label and CSS clips it at the container width.
31
+ def job_label_badge(job, label, url: nil, full: false)
31
32
  claim = job.concurrency_claims.find { |job_claim| job_claim.label == label } if job.association(:concurrency_claims).loaded?
32
33
  state_name = claim&.state_name
33
- truncated_label = truncate(label, length: 15)
34
+ truncated_label = full ? label : truncate(label, length: 15)
34
35
  state_text = t(state_name, scope: "good_job.concurrency_claims.states") if state_name
35
- title = [(label if truncated_label != label), state_text].compact.join(" · ")
36
+ title = [(label if full || truncated_label != label), state_text].compact.join(" · ")
36
37
 
37
- options = { class: "badge font-monospace text-decoration-none #{CONCURRENCY_CLAIM_BADGE_CLASSES.fetch(state_name)}" }
38
+ options = { class: "badge font-monospace text-decoration-none #{'text-truncate mw-100 ' if full}#{CONCURRENCY_CLAIM_BADGE_CLASSES.fetch(state_name)}" }
38
39
  if title.present?
39
40
  options[:title] = title
40
41
  options[:data] = { bs_toggle: "tooltip" }
@@ -62,8 +62,22 @@ module GoodJob
62
62
  # require additional synchronization.
63
63
  #
64
64
  # Per-key structure: [session_count, xact_txns, cte]
65
- # xact_txns: Array of WeakRefs to AR transaction objects
65
+ # xact_txns: Array of TransactionRefs to AR transaction objects
66
66
  class AdvisoryLockCounter
67
+ # A weak reference to an Active Record transaction. Unlike +WeakRef+,
68
+ # which uses a class variable, this can be used from non-main Ractors.
69
+ class TransactionRef
70
+ def initialize(transaction)
71
+ @ref = ObjectSpace::WeakMap.new
72
+ @ref[:transaction] = transaction
73
+ end
74
+
75
+ def open?
76
+ transaction = @ref[:transaction]
77
+ !transaction.nil? && transaction.open?
78
+ end
79
+ end
80
+
67
81
  def initialize
68
82
  if defined?(ObjectSpace::WeakKeyMap)
69
83
  # WeakKeyMap (Ruby 3.3+) holds weak keys with strong values:
@@ -144,13 +158,13 @@ module GoodJob
144
158
 
145
159
  # Record a lock acquisition.
146
160
  # Per-key structure: [session_count, xact_txns, cte]
147
- # xact_txns is an Array of WeakRefs to transaction objects
161
+ # xact_txns is an Array of TransactionRefs
148
162
  def record_lock(conn, key, cte: false, xact: false)
149
163
  prune(conn)
150
164
  counts = self[conn] || (self[conn] = {})
151
165
  session_count, xact_txns, was_cte = counts[key] || [0, [], false]
152
166
  if xact
153
- xact_txns += [WeakRef.new(conn.current_transaction)]
167
+ xact_txns += [TransactionRef.new(conn.current_transaction)]
154
168
  else
155
169
  session_count += 1
156
170
  end
@@ -191,11 +205,7 @@ module GoodJob
191
205
  return unless counts
192
206
 
193
207
  counts.each do |key, (session_count, xact_txns, cte)|
194
- live_txns = xact_txns.select do |ref|
195
- ref.weakref_alive? && ref.open?
196
- rescue WeakRef::RefError
197
- false
198
- end
208
+ live_txns = xact_txns.select(&:open?)
199
209
 
200
210
  if session_count.zero? && live_txns.empty?
201
211
  counts.delete(key)
@@ -206,8 +216,14 @@ module GoodJob
206
216
  end
207
217
  end
208
218
 
209
- ADVISORY_LOCK_COUNTS = AdvisoryLockCounter.new
210
- private_constant :ADVISORY_LOCK_COUNTS
219
+ # Lock counts are tracked per connection, and connections are not shared
220
+ # across Ractors, so each Ractor has its own counter.
221
+ # @api private
222
+ # @return [AdvisoryLockCounter]
223
+ def self.advisory_lock_counts
224
+ GoodJob::Ractors.local(:good_job_advisory_lock_counts) { AdvisoryLockCounter.new }
225
+ end
226
+
211
227
  AREL_TABLE_NEW_KWARGS = Arel::Table.instance_method(:initialize).parameters.any? { |type, name| type == :key && name == :name }
212
228
 
213
229
  included do
@@ -447,7 +463,7 @@ module GoodJob
447
463
  # @return [Boolean] whether the lock was released.
448
464
  def advisory_unlock_key(key, function: advisory_unlockable_function, connection: nil)
449
465
  conn = connection || lease_connection
450
- session_count, _xact_count, cte = ADVISORY_LOCK_COUNTS.counts_for(conn, key)
466
+ session_count, _xact_count, cte = AdvisoryLockable.advisory_lock_counts.counts_for(conn, key)
451
467
 
452
468
  if session_count <= 1 && cte
453
469
  advisory_unlock_key!(key, function: function, connection: conn)
@@ -474,7 +490,7 @@ module GoodJob
474
490
  # @return [void]
475
491
  def advisory_unlock_key!(key, function: advisory_unlockable_function, connection: nil)
476
492
  conn = connection || lease_connection
477
- _, xact_count, = ADVISORY_LOCK_COUNTS.counts_for(conn, key)
493
+ _, xact_count, = AdvisoryLockable.advisory_lock_counts.counts_for(conn, key)
478
494
 
479
495
  if xact_count.positive?
480
496
  # An active xact lock on the same key means owns_advisory_lock_key? will
@@ -617,15 +633,15 @@ module GoodJob
617
633
  end
618
634
 
619
635
  def record_advisory_lock(conn, key, cte: false, xact: false)
620
- ADVISORY_LOCK_COUNTS.record_lock(conn, key, cte: cte, xact: xact)
636
+ AdvisoryLockable.advisory_lock_counts.record_lock(conn, key, cte: cte, xact: xact)
621
637
  end
622
638
 
623
639
  def record_advisory_unlock(conn, key)
624
- ADVISORY_LOCK_COUNTS.record_unlock(conn, key)
640
+ AdvisoryLockable.advisory_lock_counts.record_unlock(conn, key)
625
641
  end
626
642
 
627
643
  def prune_advisory_locks(conn)
628
- ADVISORY_LOCK_COUNTS.prune(conn)
644
+ AdvisoryLockable.advisory_lock_counts.prune(conn)
629
645
  end
630
646
 
631
647
  # Postgres advisory unlocking function for the class
@@ -643,7 +659,7 @@ module GoodJob
643
659
  # @return [void]
644
660
  def advisory_unlock_session(connection: nil)
645
661
  conn = connection || lease_connection
646
- ADVISORY_LOCK_COUNTS.clear_session_locks(conn)
662
+ AdvisoryLockable.advisory_lock_counts.clear_session_locks(conn)
647
663
  conn.exec_query("SELECT pg_advisory_unlock_all()::text AS unlocked", 'GoodJob::Lockable Unlock Session').first[:unlocked]
648
664
  end
649
665
 
@@ -745,7 +761,7 @@ module GoodJob
745
761
  def advisory_lock_active_on?(connection, key: lockable_key)
746
762
  return false unless connection&.active?
747
763
 
748
- ADVISORY_LOCK_COUNTS.counts_for(connection, key).first.positive?
764
+ AdvisoryLockable.advisory_lock_counts.counts_for(connection, key).first.positive?
749
765
  end
750
766
 
751
767
  # Releases all advisory locks on the record that are held by the current
@@ -11,7 +11,6 @@ module GoodJob
11
11
  self.implicit_order_column = 'created_at'
12
12
 
13
13
  has_many :jobs, class_name: 'GoodJob::Job', inverse_of: :batch, foreign_key: :batch_id, dependent: nil
14
- has_many :executions, class_name: 'GoodJob::Execution', foreign_key: :batch_id, inverse_of: :batch, dependent: nil
15
14
  has_many :callback_jobs, class_name: 'GoodJob::Job', foreign_key: :batch_callback_id, dependent: nil # rubocop:disable Rails/InverseOf
16
15
 
17
16
  scope :finished, -> { where.not(finished_at: nil) }
@@ -127,7 +127,7 @@ module GoodJob
127
127
  def upsert_claim(key:, job_id:, locked_by_id:, state:)
128
128
  # Raw SQL because `upsert(update_only:)` requires Rails 7.0+
129
129
  now = Time.current
130
- lease_connection.exec_update(sanitize_sql_array([<<~SQL.squish, key, job_id, locked_by_id, state, now, now]))
130
+ lease_connection.update(sanitize_sql_array([<<~SQL.squish, key, job_id, locked_by_id, state, now, now]))
131
131
  INSERT INTO #{quoted_table_name} (key, job_id, locked_by_id, state, created_at, updated_at)
132
132
  VALUES (?, ?, ?, ?, ?, ?)
133
133
  ON CONFLICT (key, job_id)
@@ -137,6 +137,7 @@ module GoodJob # :nodoc:
137
137
  rescue ActiveRecord::RecordNotFound
138
138
  @new_record = true
139
139
  self.created_at = self.updated_at = nil
140
+ id_will_change!
140
141
  state_will_change!
141
142
  save
142
143
  end
@@ -2,10 +2,9 @@
2
2
  <div class="list-group list-group-flush text-nowrap table-jobs" role="table">
3
3
  <header class="list-group-item bg-body-tertiary">
4
4
  <div class="row small text-muted text-uppercase align-items-center">
5
- <div class="col-4"><%= t("good_job.models.batch.jobs") %></div>
5
+ <div class="col-5"><%= t("good_job.models.batch.jobs") %></div>
6
6
  <div class="d-none d-lg-block col-lg-1 text-lg-center"><%= t "good_job.models.job.queue" %></div>
7
7
  <div class="d-none d-lg-block col-lg-1 text-lg-end"><%= t "good_job.models.job.priority" %></div>
8
- <div class="d-none d-lg-block col-lg-1 text-lg-end"><%= t "good_job.models.job.labels" %></div>
9
8
  <div class="d-none d-lg-block col-lg-1 text-lg-end"><%= t "good_job.models.job.attempts" %></div>
10
9
  <div class="col text-end">
11
10
  <%= tag.button type: "button", class: "btn btn-sm text-muted", role: "button",
@@ -22,9 +21,16 @@
22
21
  <% jobs.each do |job| %>
23
22
  <div role="row" class="list-group-item list-group-item-action py-3">
24
23
  <div class="row align-items-center">
25
- <div class="col-lg-4">
24
+ <div class="col-lg-5">
26
25
  <%= tag.code link_to(job.id, job_path(job), class: "small text-muted text-decoration-none") %>
27
26
  <%= tag.h5 tag.code(link_to(job.display_name, job_path(job), class: "text-reset text-decoration-none")), class: "text-reset mb-0" %>
27
+ <% if job.labels.present? %>
28
+ <div class="mt-1 d-flex flex-wrap gap-1" aria-label="<%= t "good_job.models.job.labels" %>" role="group">
29
+ <% job.labels.each do |label| %>
30
+ <%= job_label_badge(job, label, full: true) %>
31
+ <% end %>
32
+ </div>
33
+ <% end %>
28
34
  </div>
29
35
  <div class="col-4 col-lg-1 text-lg-center">
30
36
  <div class="d-lg-none small text-muted mt-1"><%= t "good_job.models.job.queue" %></div>
@@ -34,12 +40,6 @@
34
40
  <div class="d-lg-none small text-muted mt-1"><%= t "good_job.models.job.priority" %></div>
35
41
  <span class="font-monospace fw-bold"><%= job.priority %></span>
36
42
  </div>
37
- <div class="col-4 col-lg-1 text-lg-end">
38
- <div class="d-lg-none small text-muted mt-1"><%= t "good_job.models.job.labels" %></div>
39
- <% job.labels&.each do |label| %>
40
- <%= job_label_badge(job, label) %>
41
- <% end %>
42
- </div>
43
43
  <div class="col-4 col-lg-1 text-lg-end">
44
44
  <div class="d-lg-none small text-muted mt-1"><%= t "good_job.models.job.attempts" %></div>
45
45
  <% if job.error %>
@@ -6,7 +6,7 @@
6
6
  <div class="list-group list-group-flush text-nowrap table-jobs" role="table">
7
7
  <header class="list-group-item bg-body-tertiary">
8
8
  <div class="row small text-muted text-uppercase align-items-center">
9
- <div class="col-lg-4 d-flex gap-2 flex-wrap">
9
+ <div class="col-lg-5 d-flex gap-2 flex-wrap">
10
10
  <div class="form-check d-flex flex-row px-0 mb-0">
11
11
  <%= check_box_tag('toggle_job_ids', "1", false, data: { "checkbox-toggle-target": "all", action: "change->checkbox-toggle#toggleAll" }) %>
12
12
  <%= label_tag('toggle_job_ids', t(".toggle_all_jobs"), class: "visually-hidden") %>
@@ -53,7 +53,6 @@
53
53
  </div>
54
54
  <div class="d-none d-lg-block col-lg-1 text-lg-center"><%= t "good_job.models.job.queue" %></div>
55
55
  <div class="d-none d-lg-block col-lg-1 text-lg-end"><%= t "good_job.models.job.priority" %></div>
56
- <div class="d-none d-lg-block col-lg-1 text-lg-end"><%= t "good_job.models.job.labels" %></div>
57
56
  <div class="d-none d-lg-block col-lg-1 text-lg-end"><%= t "good_job.models.job.attempts" %></div>
58
57
  <div class="col text-end">
59
58
  <%= tag.button type: "button", class: "btn btn-sm text-muted", role: "button",
@@ -80,11 +79,18 @@
80
79
  <% jobs.each do |job| %>
81
80
  <%= label_tag dom_id(job, :checkbox), id: dom_id(job), role: "row", class: "list-group-item list-group-item-action py-3" do %>
82
81
  <div class="row align-items-center">
83
- <div class="col-lg-4 d-flex">
82
+ <div class="col-lg-5 d-flex">
84
83
  <%= check_box_tag 'job_ids[]', job.id, false, id: dom_id(job, :checkbox), data: { "checkbox-toggle-target": "each", action: "change->checkbox-toggle#syncAll" } %>
85
- <div class="ms-2">
84
+ <div class="ms-2 min-width-0">
86
85
  <%= tag.code link_to(job.id, job_path(job), class: "small text-muted text-decoration-none") %>
87
86
  <%= tag.h5 tag.code(link_to(job.display_name, job_path(job), class: "text-reset text-decoration-none")), class: "text-reset mb-0" %>
87
+ <% if job.labels.present? %>
88
+ <div class="mt-1 d-flex flex-wrap gap-1" aria-label="<%= t "good_job.models.job.labels" %>" role="group">
89
+ <% job.labels.each do |label| %>
90
+ <%= job_label_badge(job, label, url: filter.to_params(label: label), full: true) %>
91
+ <% end %>
92
+ </div>
93
+ <% end %>
88
94
  <% if job.error %>
89
95
  <div class="mt-1 small">
90
96
  <strong class="small text-danger"><%= t "good_job.shared.error" %>:</strong>
@@ -101,12 +107,6 @@
101
107
  <div class="d-lg-none small text-muted mt-1"><%= t "good_job.models.job.priority" %></div>
102
108
  <%= job.priority %>
103
109
  </div>
104
- <div class="col-4 col-lg-1 text-wrap text-lg-end">
105
- <div class="d-lg-none small text-muted mt-1"><%= t "good_job.models.job.labels" %></div>
106
- <% job.labels&.each do |label| %>
107
- <%= job_label_badge(job, label, url: filter.to_params(label: label)) %>
108
- <% end %>
109
- </div>
110
110
  <div class="col-4 col-lg-1 text-lg-end">
111
111
  <div class="d-lg-none small text-muted mt-1"><%= t "good_job.models.job.attempts" %></div>
112
112
  <% if job.error %>
@@ -49,13 +49,13 @@
49
49
  {
50
50
  "warning_type": "Dangerous Eval",
51
51
  "warning_code": 13,
52
- "fingerprint": "54b7ac445eea2aa1735ae97fab1d841e15405a747ba0fdb577cf779286647534",
52
+ "fingerprint": "f7e62770ef61ea7bb2b3a0a8c19b3fa6f869fbaa51322cb56eb86f21b384ba5a",
53
53
  "check_name": "Evaluation",
54
54
  "message": "Dynamic string evaluated as code",
55
55
  "file": "lib/good_job/log_subscriber.rb",
56
- "line": 356,
56
+ "line": 365,
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\", (356 + 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\", (365 + 1))",
59
59
  "render_path": null,
60
60
  "location": {
61
61
  "type": "method",
@@ -6,6 +6,8 @@ module GoodJob
6
6
  extend ActiveSupport::Concern
7
7
 
8
8
  VALID_TYPES = [String, Symbol, Numeric, Date, Time, TrueClass, FalseClass, NilClass].freeze
9
+ # The lock and claim key for a rule without a label or key, which counts all jobs.
10
+ ALL_KEY = "all"
9
11
 
10
12
  class ConcurrencyExceededError < StandardError
11
13
  def backtrace
@@ -52,6 +54,9 @@ module GoodJob
52
54
 
53
55
  def evaluate(job, stage)
54
56
  resolved_label = resolve_label(job)
57
+ # A rule with a label that resolves to blank does not apply
58
+ return nil if @label.present? && resolved_label.blank?
59
+
55
60
  resolved_key = resolve_key(job, resolved_label)
56
61
  return nil if resolved_key.blank? && resolved_label.blank?
57
62
 
@@ -77,8 +82,10 @@ module GoodJob
77
82
  end
78
83
 
79
84
  def resolve_key(job, label)
80
- if label.present? || key.blank?
85
+ if label.present?
81
86
  "label:#{label}"
87
+ elsif key.blank?
88
+ ALL_KEY
82
89
  else
83
90
  key_value = @key.respond_to?(:call) ? job.instance_exec(&@key) : @key
84
91
  raise TypeError, "Concurrency key must be a String; was a #{key_value.class}" if key_value.present? && VALID_TYPES.none? { |type| key_value.is_a?(type) }
@@ -117,7 +124,7 @@ module GoodJob
117
124
  elsif key_explicit? && key.present?
118
125
  "key:#{key}"
119
126
  else
120
- "all"
127
+ ALL_KEY
121
128
  end
122
129
  end
123
130
 
@@ -11,6 +11,13 @@ module GoodJob
11
11
  # @return [Array<GoodJob::Adapter>, nil]
12
12
  cattr_reader :instances, default: Concurrent::Array.new, instance_reader: false
13
13
 
14
+ # Mutable per-adapter state (the capsule and whether it was started), kept
15
+ # off the adapter instance so that the adapter itself can be made
16
+ # Ractor-shareable. Only accessed from the main Ractor.
17
+ Runtime = Struct.new(:capsule, :async_started)
18
+ RUNTIMES = Concurrent::Map.new
19
+ private_constant :Runtime, :RUNTIMES
20
+
14
21
  # @param execution_mode [Symbol, nil] specifies how and where jobs should be executed. You can also set this with the environment variable +GOOD_JOB_EXECUTION_MODE+.
15
22
  #
16
23
  # - +:inline+ executes jobs immediately in whatever process queued them (usually the web server process). This should only be used in test and development environments.
@@ -28,7 +35,7 @@ module GoodJob
28
35
  def initialize(execution_mode: nil, _capsule: GoodJob.capsule) # rubocop:disable Lint/UnderscorePrefixedVariableName
29
36
  @_execution_mode_override = execution_mode
30
37
  GoodJob::Configuration.validate_execution_mode(@_execution_mode_override) if @_execution_mode_override
31
- @capsule = _capsule
38
+ RUNTIMES[self] = Runtime.new(_capsule, false)
32
39
 
33
40
  start_async if GoodJob.async_ready?
34
41
  self.class.instances << self
@@ -75,16 +82,19 @@ module GoodJob
75
82
  lock_id = nil
76
83
 
77
84
  if execute_inline?
78
- @capsule.tracker.register
85
+ lock_id = register_tracker
79
86
  tracker_registered = true
80
- lock_id = @capsule.tracker.id_for_lock
81
87
  end
82
88
 
83
89
  begin
84
90
  GoodJob::Job.transaction(requires_new: true, joinable: false) do
85
- column_names = GoodJob::Job.column_names
86
- job_attributes = jobs.map { |job| job.attributes.slice(*column_names) }
87
- results = GoodJob::Job.insert_all(job_attributes, returning: %w[id active_job_id]) # rubocop:disable Rails/SkipsModelValidations
91
+ results = if GoodJob::Ractors.main?
92
+ column_names = GoodJob::Job.column_names
93
+ job_attributes = jobs.map { |job| job.attributes.slice(*column_names) }
94
+ GoodJob::Job.insert_all(job_attributes, returning: %w[id active_job_id]) # rubocop:disable Rails/SkipsModelValidations
95
+ else
96
+ insert_individually(jobs)
97
+ end
88
98
 
89
99
  job_id_to_provider_job_id = results.to_h { |result| [result['active_job_id'], result['id']] }
90
100
  active_jobs.each do |active_job|
@@ -120,16 +130,16 @@ module GoodJob
120
130
  end
121
131
  ensure
122
132
  inline_jobs.each(&:advisory_unlock)
123
- @capsule.tracker.unregister if tracker_registered
133
+ unregister_tracker if tracker_registered
124
134
  tracker_registered = false
125
135
  end
126
136
  elsif tracker_registered
127
- @capsule.tracker.unregister
137
+ unregister_tracker
128
138
  tracker_registered = false
129
139
  end
130
140
  rescue StandardError
131
141
  if tracker_registered
132
- @capsule.tracker.unregister
142
+ unregister_tracker
133
143
  tracker_registered = false
134
144
  end
135
145
  raise
@@ -147,7 +157,7 @@ module GoodJob
147
157
  state = { queue_name: queue_name, count: jobs_by_queue_and_scheduled_at.size }
148
158
  state[:scheduled_at] = scheduled_at if scheduled_at
149
159
 
150
- executed_locally = execute_async? && @capsule&.create_thread(state)
160
+ executed_locally = execute_async? && create_capsule_thread(state)
151
161
  unless executed_locally
152
162
  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
163
  Notifier.notify(state) unless state[:count].zero?
@@ -183,8 +193,7 @@ module GoodJob
183
193
  )
184
194
  elsif will_execute_inline
185
195
  lock_strategy = GoodJob::Job.effective_lock_strategy
186
- @capsule.tracker.register
187
- lock_id = @capsule.tracker.id_for_lock
196
+ lock_id = register_tracker
188
197
  tracker_registered = true
189
198
  begin
190
199
  job = case lock_strategy
@@ -198,12 +207,12 @@ module GoodJob
198
207
  InlineBuffer.perform_now_or_defer do
199
208
  perform_inline(job, notify: send_notify?(active_job), already_claimed: lock_strategy != :advisory, advisory_unlock: lock_strategy != :skiplocked)
200
209
  ensure
201
- @capsule.tracker.unregister if tracker_registered
210
+ unregister_tracker if tracker_registered
202
211
  tracker_registered = false
203
212
  end
204
213
  rescue StandardError
205
214
  if tracker_registered
206
- @capsule.tracker.unregister
215
+ unregister_tracker
207
216
  tracker_registered = false
208
217
  end
209
218
  raise
@@ -215,7 +224,7 @@ module GoodJob
215
224
  )
216
225
 
217
226
  if job
218
- executed_locally = execute_async? && @capsule&.create_thread(job.job_state)
227
+ executed_locally = execute_async? && create_capsule_thread(job.job_state)
219
228
  Notifier.notify(job.job_state) if !executed_locally && send_notify?(active_job)
220
229
  end
221
230
  end
@@ -246,8 +255,9 @@ module GoodJob
246
255
  # * A positive number will wait that many seconds before stopping any remaining active threads.
247
256
  # @return [void]
248
257
  def shutdown(timeout: NONE)
249
- @capsule&.shutdown(timeout: timeout)
250
- @_async_started = false
258
+ runtime = RUNTIMES[self]
259
+ runtime.capsule&.shutdown(timeout: timeout)
260
+ runtime.async_started = false
251
261
  end
252
262
 
253
263
  # This adapter's execution mode
@@ -282,19 +292,52 @@ module GoodJob
282
292
  def start_async
283
293
  return unless execute_async?
284
294
 
285
- @capsule.start
286
- @capsule.lower_thread_priority = true if GoodJob.configuration.lower_thread_priority.in?([true, nil])
287
- @_async_started = true
295
+ runtime = RUNTIMES[self]
296
+ runtime.capsule.start
297
+ runtime.capsule.lower_thread_priority = true if GoodJob.configuration.lower_thread_priority.in?([true, nil])
298
+ runtime.async_started = true
288
299
  end
289
300
 
290
301
  # Whether the async executors are running
291
302
  # @return [Boolean]
292
303
  def async_started?
293
- @_async_started
304
+ GoodJob::Ractors.on_main(self) { RUNTIMES[self].async_started }
294
305
  end
295
306
 
296
307
  private
297
308
 
309
+ # The capsule is not Ractor-shareable, so it is always used on the main Ractor.
310
+ def create_capsule_thread(state)
311
+ job_state = GoodJob::Ractors.main? ? state : GoodJob::Ractors.make_shareable(state.dup)
312
+ GoodJob::Ractors.on_main(self) { RUNTIMES[self].capsule&.create_thread(job_state) }
313
+ end
314
+
315
+ def register_tracker
316
+ GoodJob::Ractors.on_main(self) do
317
+ tracker = RUNTIMES[self].capsule.tracker
318
+ tracker.register
319
+ tracker.id_for_lock
320
+ end
321
+ end
322
+
323
+ def unregister_tracker
324
+ GoodJob::Ractors.on_main(self) { RUNTIMES[self].capsule.tracker.unregister }
325
+ end
326
+
327
+ def tracker_id_for_lock
328
+ GoodJob::Ractors.on_main(self) { RUNTIMES[self].capsule.tracker.id_for_lock }
329
+ end
330
+
331
+ # Active Record does not support +insert_all+ outside of the main Ractor.
332
+ def insert_individually(jobs)
333
+ jobs.filter_map do |job|
334
+ GoodJob::Job.transaction(requires_new: true) { job.save!(validate: false) }
335
+ { 'id' => job.id, 'active_job_id' => job.active_job_id }
336
+ rescue ActiveRecord::RecordNotUnique
337
+ nil
338
+ end
339
+ end
340
+
298
341
  def send_notify?(active_job)
299
342
  return false unless GoodJob.configuration.enable_listen_notify
300
343
  return true unless active_job.respond_to?(:good_job_notify)
@@ -309,7 +352,7 @@ module GoodJob
309
352
  retried_job = nil
310
353
 
311
354
  loop do
312
- result = job.perform(lock_id: @capsule.tracker.id_for_lock, already_claimed: already_claimed)
355
+ result = job.perform(lock_id: tracker_id_for_lock, already_claimed: already_claimed)
313
356
  already_claimed = false # only skip lock-write on first iteration
314
357
  retried_job = result.retried_job
315
358
  break if retried_job.nil? || retried_job.scheduled_at.nil? || retried_job.scheduled_at > Time.current
@@ -528,6 +528,15 @@ module GoodJob
528
528
  (options[:dequeue_query_sort] || rails_config[:dequeue_query_sort] || :created_at).to_sym
529
529
  end
530
530
 
531
+ # Resolves lazily memoized values so the configuration can be frozen.
532
+ # @return [self]
533
+ def prepare_for_sharing
534
+ in_webserver?
535
+ validator
536
+ @_rails_config = rails_config
537
+ self
538
+ end
539
+
531
540
  private
532
541
 
533
542
  # Reads a configuration value from one or more deprecated sources, emitting a
@@ -586,7 +595,7 @@ module GoodJob
586
595
  end
587
596
 
588
597
  def rails_config
589
- Rails.application.config.good_job
598
+ @_rails_config || Rails.application.config.good_job
590
599
  end
591
600
  end
592
601
  end
@@ -10,6 +10,10 @@ module GoodJob
10
10
  config.good_job = ActiveSupport::OrderedOptions.new
11
11
  config.good_job.cron = {}
12
12
 
13
+ initializer "good_job.ractors" do |app|
14
+ app.singleton_class.prepend(GoodJob::Ractors::ApplicationExtension) if GoodJob::Ractors.enabled? && app.respond_to?(:ractorize!)
15
+ end
16
+
13
17
  initializer "good_job.logger" do |_app|
14
18
  ActiveSupport.on_load(:good_job) do
15
19
  self.logger = ::Rails.logger if GoodJob.logger == GoodJob::DEFAULT_LOGGER
@@ -316,6 +316,15 @@ module GoodJob
316
316
  def reset_logger
317
317
  @_logger = nil
318
318
  end
319
+
320
+ # Builds {LogSubscriber.logger} so that it can be read from non-main
321
+ # Ractors. A single logger is used directly because
322
+ # +ActiveSupport::BroadcastLogger+ is not Ractor-shareable.
323
+ # @return [void]
324
+ def prepare_for_sharing
325
+ @_loggers = GoodJob::Ractors.make_shareable(loggers)
326
+ @_logger = loggers.size == 1 ? loggers.first : GoodJob::Ractors.try_make_shareable(logger)
327
+ end
319
328
  end
320
329
 
321
330
  private
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoodJob
4
+ # Compatibility layer for running GoodJob in a Ractor-ized Rails application.
5
+ #
6
+ # Delegates to +ActiveSupport::Ractors+ when the Rails version provides it,
7
+ # and otherwise falls back to behavior identical to a single-Ractor process.
8
+ # All Ractor-related calls in GoodJob should go through this module so that
9
+ # older Rails versions are unaffected and changes to Rails' (internal)
10
+ # Ractor API are contained in one place.
11
+ module Ractors
12
+ NATIVE = begin
13
+ require "active_support/ractors"
14
+ ActiveSupport::Ractors.respond_to?(:on_main) && ActiveSupport::Ractors.respond_to?(:store_if_absent)
15
+ rescue LoadError
16
+ false
17
+ end
18
+
19
+ # Prepended to the Rails application's singleton class so that +Rails.application.ractorize!+
20
+ # also makes GoodJob's global state shareable.
21
+ module ApplicationExtension
22
+ def ractorize!
23
+ GoodJob.ractorize!
24
+ super
25
+ end
26
+ end
27
+
28
+ LOCAL_STORAGE = Concurrent::Map.new
29
+ private_constant :LOCAL_STORAGE
30
+
31
+ class << self
32
+ # Whether the current Rails version supports Ractors.
33
+ # @return [Boolean]
34
+ def enabled?
35
+ NATIVE
36
+ end
37
+
38
+ # Whether the current Ractor is the main Ractor.
39
+ # @return [Boolean]
40
+ def main?
41
+ NATIVE ? ActiveSupport::Ractors.main? : true
42
+ end
43
+
44
+ # Deep-freezes +obj+ so it can be shared across Ractors.
45
+ # @return [Object]
46
+ def make_shareable(obj)
47
+ NATIVE ? ActiveSupport::Ractors.make_shareable(obj) : obj
48
+ end
49
+
50
+ # Attempts to make +obj+, which may contain user-provided procs,
51
+ # shareable. If it cannot be made shareable, the application's
52
+ # +ActiveSupport::Ractors.unshareable_proc_action+ decides whether to
53
+ # raise, warn, or return it unchanged.
54
+ # @return [Object]
55
+ def try_make_shareable(obj)
56
+ return obj unless NATIVE
57
+
58
+ ActiveSupport::Ractors.make_shareable(obj)
59
+ rescue Ractor::IsolationError
60
+ raise unless ActiveSupport::Ractors.unshareable_proc_action
61
+
62
+ ActiveSupport::Ractors.try_make_shareable(obj)
63
+ end
64
+
65
+ # Attempts to make a user-provided proc shareable, according to the
66
+ # application's +ActiveSupport::Ractors.unshareable_proc_action+.
67
+ # @return [Proc, nil]
68
+ def try_shareable_proc(proc)
69
+ return proc unless NATIVE && proc.is_a?(Proc)
70
+
71
+ ActiveSupport::Ractors.try_shareable_proc(proc)
72
+ end
73
+
74
+ # Runs the block on the main Ractor and returns its (shareable) result.
75
+ # When already on the main Ractor, or Ractors are unsupported, the block
76
+ # is called directly.
77
+ def on_main(obj = nil, &block)
78
+ if NATIVE
79
+ ActiveSupport::Ractors.on_main(obj, &block)
80
+ else
81
+ obj.instance_eval(&block)
82
+ end
83
+ end
84
+
85
+ # Returns the value stored under +key+ for the current Ractor,
86
+ # initializing it with the block on first access. Without Ractor
87
+ # support, the value is process-wide.
88
+ def local(key, &block)
89
+ if NATIVE
90
+ ActiveSupport::Ractors.store_if_absent(key, &block)
91
+ else
92
+ LOCAL_STORAGE.compute_if_absent(key, &block)
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module GoodJob
4
4
  # GoodJob gem version.
5
- VERSION = '4.22.0'
5
+ VERSION = '4.22.1'
6
6
 
7
7
  # GoodJob version as Gem::Version object
8
8
  GEM_VERSION = Gem::Version.new(VERSION)
data/lib/good_job.rb CHANGED
@@ -5,6 +5,7 @@ require "active_job/queue_adapters"
5
5
 
6
6
  require_relative "good_job/version"
7
7
  require_relative "good_job/engine"
8
+ require_relative "good_job/ractors"
8
9
 
9
10
  require_relative "good_job/adapter"
10
11
  require_relative "good_job/adapter/inline_buffer"
@@ -77,7 +78,8 @@ module GoodJob
77
78
  # @return [ActiveRecord::Base]
78
79
  # @example Change the base class:
79
80
  # GoodJob.active_record_parent_class = "CustomApplicationRecord"
80
- mattr_accessor :active_record_parent_class, default: nil
81
+ singleton_class.attr_accessor :active_record_parent_class
82
+ self.active_record_parent_class = nil
81
83
 
82
84
  # @!attribute [rw] logger
83
85
  # @!scope class
@@ -86,7 +88,8 @@ module GoodJob
86
88
  # @return [Logger, nil]
87
89
  # @example Output GoodJob logs to a file:
88
90
  # GoodJob.logger = ActiveSupport::TaggedLogging.new(ActiveSupport::Logger.new("log/my_logs.log"))
89
- mattr_accessor :logger, default: DEFAULT_LOGGER
91
+ singleton_class.attr_accessor :logger
92
+ self.logger = DEFAULT_LOGGER
90
93
 
91
94
  # @!attribute [rw] preserve_job_records
92
95
  # @!scope class
@@ -99,7 +102,8 @@ module GoodJob
99
102
  # @example Preserve only jobs that were discarded
100
103
  # GoodJob.preserve_job_records = ->(active_job, exception, error_event) { error_event == :discarded }
101
104
  # @return [Boolean, Symbol, Proc, nil]
102
- mattr_accessor :preserve_job_records, default: true
105
+ singleton_class.attr_accessor :preserve_job_records
106
+ self.preserve_job_records = true
103
107
 
104
108
  # @!attribute [rw] retry_on_unhandled_error
105
109
  # @!scope class
@@ -108,7 +112,8 @@ module GoodJob
108
112
  # If +false+, jobs will be discarded or marked as finished if they raise an instance of a handled exception.
109
113
  # @see GoodJob.handled_exceptions
110
114
  # @return [Boolean, nil]
111
- mattr_accessor :retry_on_unhandled_error, default: false
115
+ singleton_class.attr_accessor :retry_on_unhandled_error
116
+ self.retry_on_unhandled_error = false
112
117
 
113
118
  # @!attribute [rw] handled_exceptions
114
119
  # @!scope class
@@ -118,7 +123,8 @@ module GoodJob
118
123
  # @example Also rescue ScriptError
119
124
  # GoodJob.handled_exceptions = [StandardError, NotImplementedError, ScriptError]
120
125
  # @return [Array<Class, String>]
121
- mattr_accessor :handled_exceptions, default: [StandardError, NotImplementedError]
126
+ singleton_class.attr_accessor :handled_exceptions
127
+ self.handled_exceptions = [StandardError, NotImplementedError]
122
128
 
123
129
  # Resolved exception classes from {handled_exceptions}.
124
130
  # @return [Array<Class>]
@@ -134,19 +140,22 @@ module GoodJob
134
140
  # # config/initializers/good_job.rb
135
141
  # GoodJob.on_thread_error = -> (exception) { Raven.capture_exception(exception) }
136
142
  # @return [Proc, nil]
137
- mattr_accessor :on_thread_error, default: nil
143
+ singleton_class.attr_accessor :on_thread_error
144
+ self.on_thread_error = nil
138
145
 
139
146
  # @!attribute [rw] configuration
140
147
  # @!scope class
141
148
  # Global configuration object for GoodJob.
142
149
  # @return [GoodJob::Configuration, nil]
143
- mattr_accessor :configuration, default: GoodJob::Configuration.new({})
150
+ singleton_class.attr_accessor :configuration
151
+ self.configuration = GoodJob::Configuration.new({})
144
152
 
145
153
  # @!attribute [rw] capsule
146
154
  # @!scope class
147
155
  # Global/default execution capsule for GoodJob.
148
156
  # @return [GoodJob::Capsule, nil]
149
- mattr_accessor :capsule, default: GoodJob::Capsule.new(configuration: configuration)
157
+ singleton_class.attr_accessor :capsule
158
+ self.capsule = GoodJob::Capsule.new(configuration: configuration)
150
159
 
151
160
  # Called with exception when a GoodJob thread raises an exception
152
161
  # @param exception [Exception] Exception that was raised
@@ -167,7 +176,57 @@ module GoodJob
167
176
  self._active_record_configuration = block
168
177
  end
169
178
 
170
- mattr_accessor :_active_record_configuration, default: nil
179
+ singleton_class.attr_accessor :_active_record_configuration
180
+ self._active_record_configuration = nil
181
+
182
+ # Makes GoodJob's global state Ractor-shareable so that jobs can be enqueued
183
+ # and performed from non-main Ractors. Called automatically by
184
+ # +Rails.application.ractorize!+ on Rails versions that support Ractors.
185
+ # Background execution (capsules, schedulers, the notifier) remains on the
186
+ # main Ractor.
187
+ # @return [void]
188
+ def self.ractorize!
189
+ _load_record_schemas
190
+ LogSubscriber.prepare_for_sharing
191
+ Ractors.make_shareable(configuration.prepare_for_sharing)
192
+ Ractors.make_shareable(handled_exceptions)
193
+ Ractors.make_shareable(logger)
194
+ self.preserve_job_records = Ractors.try_shareable_proc(preserve_job_records)
195
+ self.on_thread_error = Ractors.try_shareable_proc(on_thread_error)
196
+ _make_job_classes_shareable
197
+ end
198
+
199
+ # Concurrency and label settings are class attributes that may hold
200
+ # user-provided procs; they are frozen in place on each job class.
201
+ # @return [void]
202
+ def self._make_job_classes_shareable
203
+ attributes = %i[good_job_concurrency_config good_job_concurrency_rules good_job_labels]
204
+ [ActiveJob::Base, *ActiveJob::Base.descendants].each do |job_class|
205
+ attributes.each do |attribute|
206
+ Ractors.try_make_shareable(job_class.public_send(attribute)) if job_class.respond_to?(attribute)
207
+ end
208
+ end
209
+ end
210
+
211
+ # Active Record schemas and GoodJob's database capability checks are
212
+ # lazily loaded into class-level state, which can only be written from
213
+ # the main Ractor.
214
+ # @return [void]
215
+ def self._load_record_schemas
216
+ GoodJob::AdvisoryLockable.hash_function
217
+ GoodJob::BaseRecord.descendants.each do |record_class|
218
+ next if record_class.abstract_class?
219
+
220
+ record_class.load_schema
221
+ record_class.define_attribute_methods
222
+ end
223
+ GoodJob::Job.lock_type_column_exists?
224
+ GoodJob::Job.supports_cte_materialization_specifiers?
225
+ GoodJob::Job.database_supports_websearch_to_tsquery?
226
+ GoodJob::BatchRecord.database_supports_websearch_to_tsquery?
227
+ rescue ActiveRecord::ActiveRecordError => e
228
+ logger&.warn("GoodJob could not load its database schema before ractorizing: #{e.message}")
229
+ end
171
230
 
172
231
  # Stop executing jobs.
173
232
  # GoodJob does its work in pools of background threads.
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.22.0
4
+ version: 4.22.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ben Sheldon
@@ -432,6 +432,7 @@ files:
432
432
  - lib/good_job/probe_server/not_found_app.rb
433
433
  - lib/good_job/probe_server/simple_handler.rb
434
434
  - lib/good_job/probe_server/webrick_handler.rb
435
+ - lib/good_job/ractors.rb
435
436
  - lib/good_job/safe_state.rb
436
437
  - lib/good_job/scheduler.rb
437
438
  - lib/good_job/sd_notify.rb