pgbus 0.14.0 → 0.14.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +30 -0
  3. data/app/models/pgbus/batch_entry.rb +78 -6
  4. data/app/models/pgbus/batch_execution.rb +28 -0
  5. data/app/models/pgbus/blocked_execution.rb +2 -2
  6. data/app/models/pgbus/uniqueness_key.rb +50 -4
  7. data/app/views/pgbus/batches/_batches_table.html.erb +3 -3
  8. data/app/views/pgbus/batches/show.html.erb +7 -7
  9. data/config/locales/da.yml +2 -2
  10. data/config/locales/de.yml +2 -2
  11. data/config/locales/en.yml +2 -2
  12. data/config/locales/es.yml +2 -2
  13. data/config/locales/fi.yml +2 -2
  14. data/config/locales/fr.yml +2 -2
  15. data/config/locales/it.yml +2 -2
  16. data/config/locales/ja.yml +2 -2
  17. data/config/locales/nb.yml +2 -2
  18. data/config/locales/nl.yml +2 -2
  19. data/config/locales/pt.yml +2 -2
  20. data/config/locales/sv.yml +2 -2
  21. data/lib/generators/pgbus/add_batch_callback_jobs_generator.rb +47 -0
  22. data/lib/generators/pgbus/add_batch_executions_generator.rb +44 -0
  23. data/lib/generators/pgbus/templates/add_batch_callback_jobs.rb.erb +13 -0
  24. data/lib/generators/pgbus/templates/add_batch_executions.rb.erb +50 -0
  25. data/lib/generators/pgbus/templates/initializer.rb.erb +2 -0
  26. data/lib/generators/pgbus/templates/migration.rb.erb +23 -2
  27. data/lib/pgbus/active_job/adapter.rb +142 -28
  28. data/lib/pgbus/active_job/batch_id.rb +48 -0
  29. data/lib/pgbus/active_job/executor.rb +45 -9
  30. data/lib/pgbus/batch/sweep.rb +163 -0
  31. data/lib/pgbus/batch.rb +448 -63
  32. data/lib/pgbus/bus_record.rb +15 -1
  33. data/lib/pgbus/client.rb +191 -15
  34. data/lib/pgbus/concurrency/blocked_execution.rb +14 -1
  35. data/lib/pgbus/configuration.rb +24 -1
  36. data/lib/pgbus/engine.rb +1 -0
  37. data/lib/pgbus/generators/migration_detector.rb +30 -0
  38. data/lib/pgbus/instrumentation.rb +5 -0
  39. data/lib/pgbus/outbox/poller.rb +6 -7
  40. data/lib/pgbus/process/dispatcher.rb +41 -17
  41. data/lib/pgbus/recurring/schedule.rb +12 -1
  42. data/lib/pgbus/uniqueness.rb +35 -7
  43. data/lib/pgbus/version.rb +1 -1
  44. data/lib/pgbus/web/data_source.rb +47 -9
  45. metadata +8 -1
data/lib/pgbus/batch.rb CHANGED
@@ -5,118 +5,358 @@ require "json"
5
5
 
6
6
  module Pgbus
7
7
  class Batch
8
+ class AlreadyFinished < Error; end
9
+
8
10
  METADATA_KEY = "pgbus_batch_id"
9
11
 
10
12
  attr_reader :batch_id, :properties, :description,
11
- :on_finish, :on_success, :on_discard
13
+ :on_finish, :on_success, :on_failure
14
+
15
+ def on_discard
16
+ on_failure
17
+ end
18
+
19
+ def initialize(on_finish: nil, on_success: nil, on_discard: nil, on_failure: nil, description: nil, properties: {})
20
+ raise ArgumentError, "pass on_failure: only — on_discard: is a deprecated alias" if on_discard && on_failure
21
+
22
+ if on_discard
23
+ Pgbus.logger.warn do
24
+ "[Pgbus] Batch on_discard: is deprecated and will be removed in 1.0 — use on_failure: instead"
25
+ end
26
+ end
12
27
 
13
- def initialize(on_finish: nil, on_success: nil, on_discard: nil, description: nil, properties: {})
14
28
  @batch_id = SecureRandom.uuid
15
29
  @on_finish = on_finish
16
30
  @on_success = on_success
17
- @on_discard = on_discard
31
+ @on_failure = on_failure || on_discard
18
32
  @description = description
19
33
  @properties = properties
20
- @job_count = 0
34
+ @started = false
21
35
  end
22
36
 
23
- # Enqueue a group of jobs as a batch.
24
- # Jobs enqueued inside the block are tracked as part of this batch.
37
+ # Enqueue a group of jobs as a batch. Jobs enqueued inside the block join
38
+ # this batch.
39
+ #
40
+ # Re-callable while the batch is unfinished (open batches): the second and
41
+ # later calls add to the existing group instead of creating a new record.
42
+ # A job running inside the batch reaches its own handle through
43
+ # +ActiveJob::Base#batch+ and can add siblings the same way.
44
+ #
45
+ # Raises Pgbus::Batch::AlreadyFinished once the batch has finished.
25
46
  def enqueue(&)
47
+ return reopen(&) if @started
48
+
26
49
  create_record
50
+ @started = true
27
51
  count_jobs(&)
28
- update_total
52
+ start_processing
53
+ self
54
+ end
55
+
56
+ # --- readers on a live batch ---------------------------------------
57
+
58
+ def status = record&.status
59
+
60
+ def total_jobs = record&.total_jobs.to_i
61
+
62
+ def completed_jobs = record&.completed_jobs.to_i
63
+
64
+ def failed_jobs = record ? self.class.send(:failure_count, record) : 0
65
+
66
+ # Jobs still outstanding. Execution rows are the authority; unmigrated
67
+ # installs fall back to counter arithmetic.
68
+ def pending_jobs
69
+ return [total_jobs - completed_jobs - failed_jobs, 0].max unless self.class.executions_migrated?
70
+
71
+ BatchExecution.where(batch_id: batch_id).count
72
+ end
73
+
74
+ def progress_percentage
75
+ total = total_jobs
76
+ return 100 unless total.positive?
77
+
78
+ ((completed_jobs + failed_jobs) * 100) / total
79
+ end
80
+
81
+ def finished? = status == "finished"
82
+
83
+ # Cached row behind the delegated readers. Re-read with #reload.
84
+ def record
85
+ @record = BatchEntry.find_by(batch_id: batch_id) unless defined?(@record)
86
+ @record
87
+ end
88
+
89
+ def reload
90
+ remove_instance_variable(:@record) if defined?(@record)
29
91
  self
30
92
  end
31
93
 
32
94
  # Record a completed job. Returns the batch row after update.
33
- def self.job_completed(batch_id)
34
- update_counter(batch_id, "completed_jobs")
95
+ def self.job_completed(batch_id, job_id: nil)
96
+ if executions_migrated?
97
+ job_id ? resolve_execution(batch_id, job_id, "completed_jobs") : signal_without_row(batch_id, "completed_jobs")
98
+ else
99
+ update_counter(batch_id, "completed_jobs")
100
+ end
35
101
  end
36
102
 
37
- # Record a discarded (dead-lettered) job. Returns the batch row after update.
38
- def self.job_discarded(batch_id)
39
- update_counter(batch_id, "discarded_jobs")
103
+ # Record a discarded/dead-lettered job. Returns the batch row after update.
104
+ def self.job_discarded(batch_id, job_id: nil)
105
+ if executions_migrated?
106
+ job_id ? resolve_execution(batch_id, job_id, "failed_jobs") : signal_without_row(batch_id, "failed_jobs")
107
+ else
108
+ update_counter(batch_id, "discarded_jobs")
109
+ end
40
110
  end
41
111
 
42
- # Find a batch record by ID. Returns a hash or nil.
112
+ # Find a batch by id. Returns a rehydrated Pgbus::Batch handle, or nil.
113
+ #
114
+ # BREAKING (pre-1.0): this used to return the raw attributes Hash. Read
115
+ # the same values off the handle (#status, #total_jobs, #properties, …),
116
+ # or query Pgbus::BatchEntry directly for a row.
43
117
  def self.find(batch_id)
44
- BatchEntry.find_by(batch_id: batch_id)&.attributes
118
+ record = BatchEntry.find_by(batch_id: batch_id)
119
+ return nil unless record
120
+
121
+ rehydrate(record)
45
122
  end
46
123
 
124
+ # Build a handle around an existing row without creating a new batch.
125
+ def self.rehydrate(record)
126
+ batch = allocate
127
+ batch.send(:initialize_from_record, record)
128
+ batch
129
+ end
130
+ private_class_method :rehydrate
131
+
47
132
  # Delete finished batches older than the given threshold.
48
133
  def self.cleanup(older_than:)
49
134
  BatchEntry.stale(before: older_than).delete_all
50
135
  end
51
136
 
52
- private
137
+ def self.executions_migrated?
138
+ return true if @executions_migrated
53
139
 
54
- def create_record
55
- BatchEntry.create!(
56
- batch_id: batch_id,
57
- description: description,
58
- on_finish_class: on_finish&.name,
59
- on_success_class: on_success&.name,
60
- on_discard_class: on_discard&.name,
61
- properties: JSON.generate(properties),
62
- status: "pending"
63
- )
140
+ result = begin
141
+ BatchExecution.table_exists?
142
+ rescue StandardError
143
+ false
144
+ end
145
+ @executions_migrated = true if result
146
+ result
64
147
  end
65
148
 
66
- def count_jobs(&)
67
- previous_batch_id = Thread.current[:pgbus_batch_id]
68
- previous_count = Thread.current[:pgbus_batch_job_count]
149
+ def self.warn_callback_jobs_unmigrated
150
+ return if @warned_callback_jobs_unmigrated
69
151
 
70
- Thread.current[:pgbus_batch_id] = batch_id
71
- Thread.current[:pgbus_batch_job_count] = 0
152
+ @warned_callback_jobs_unmigrated = true
153
+ Pgbus.logger.warn do
154
+ "[Pgbus] Batch callback configured as an ActiveJob instance, but pgbus_batches has no " \
155
+ "on_*_job columns yet — .set options (queue, wait, priority) are ignored until " \
156
+ "`rails generate pgbus:add_batch_callback_jobs` runs"
157
+ end
158
+ end
72
159
 
73
- yield
160
+ def self.reset_executions_migrated_cache!
161
+ @executions_migrated = nil
162
+ @callback_jobs_migrated = nil
163
+ @warned_callback_jobs_unmigrated = nil
164
+ end
74
165
 
75
- @job_count = Thread.current[:pgbus_batch_job_count] || 0
76
- ensure
77
- Thread.current[:pgbus_batch_id] = previous_batch_id
78
- Thread.current[:pgbus_batch_job_count] = previous_count
166
+ # True once the on_finish_job / on_success_job / on_failure_job jsonb
167
+ # columns exist (issue #415). Until then, configured callback instances
168
+ # have nowhere to live and only bare classes are stored.
169
+ def self.callback_jobs_migrated?
170
+ return true if @callback_jobs_migrated
171
+
172
+ result = begin
173
+ BatchEntry.column_names.include?("on_finish_job")
174
+ rescue StandardError
175
+ false
176
+ end
177
+ @callback_jobs_migrated = true if result
178
+ result
79
179
  end
80
180
 
81
- def update_total
82
- if @job_count.zero?
83
- # Finish empty batches immediately — no jobs to signal completion
84
- BatchEntry.where(batch_id: batch_id).update_all(
85
- total_jobs: 0,
86
- status: "finished",
87
- finished_at: Time.current
88
- )
89
- fire_empty_batch_callbacks
90
- else
91
- BatchEntry.where(batch_id: batch_id).update_all(total_jobs: @job_count, status: "processing")
181
+ # Count tagged payloads into their batch and insert their execution rows,
182
+ # in ONE transaction, BEFORE any message is sent (issue #423). Every
183
+ # commit point keeps the invariant
184
+ # total_jobs == outstanding rows + completed_jobs + failed_jobs
185
+ # which is what lets a finish never race an add: the guarded increment
186
+ # raises AlreadyFinished here — at perform_later, before send — when the
187
+ # batch has already finished. Pass an Array to count a bulk send once.
188
+ def self.track_enqueue(payloads)
189
+ payloads = payloads.is_a?(Hash) ? [payloads] : Array(payloads)
190
+ batch_id = payloads.first&.fetch(METADATA_KEY, nil)
191
+ return if payloads.empty? || batch_id.nil?
192
+
193
+ migrated = executions_migrated?
194
+ BatchEntry.transaction do
195
+ BatchEntry.increment_total_jobs!(batch_id, payloads.size)
196
+ next unless migrated
197
+
198
+ payloads.each do |payload|
199
+ job_id = payload["job_id"]
200
+ next unless job_id
201
+
202
+ BatchExecution.insert_for!(batch_id: batch_id, job_id: job_id, queue_name: payload["queue_name"])
203
+ end
92
204
  end
93
205
  end
94
206
 
95
- def fire_empty_batch_callbacks
96
- record = BatchEntry.find_by(batch_id: batch_id)
97
- return unless record
207
+ # A retry_on re-enqueue of a job that is already a batch member (issue
208
+ # #424): same ActiveJob job_id, new PGMQ message. It keeps the ONE
209
+ # execution row it already has (ON CONFLICT DO NOTHING) and is never
210
+ # counted again — the batch waits for this job's terminal outcome, not
211
+ # its first attempt. The backfill after send re-points the row at the new
212
+ # message.
213
+ def self.track_retry(payload)
214
+ return unless executions_migrated?
98
215
 
99
- properties = parse_properties(record.properties)
100
- self.class.send(:enqueue_callback, record.on_finish_class, properties) if record.on_finish_class
101
- self.class.send(:enqueue_callback, record.on_success_class, properties) if record.on_success_class
216
+ batch_id = payload[METADATA_KEY]
217
+ job_id = payload["job_id"]
218
+ return unless batch_id && job_id
219
+
220
+ BatchExecution.insert_for!(batch_id: batch_id, job_id: job_id, queue_name: payload["queue_name"])
102
221
  end
103
222
 
104
- def parse_properties(props)
105
- JSON.parse(props.presence || "{}")
106
- rescue JSON::ParserError => e
107
- Pgbus.logger.error { "[Pgbus] Invalid batch properties JSON: #{e.message}" }
108
- {}
223
+ # --- "this job re-enqueued itself" bookkeeping ---------------------
224
+ #
225
+ # retry_on re-enqueues from INSIDE perform_now and returns normally, so the
226
+ # executor cannot tell a retried attempt from a successful one. The
227
+ # adapter records the job_id here after a successful retry send; the
228
+ # executor consults it after perform and skips the completion signal, then
229
+ # clears it per execute. Thread.current[] is fiber-local, which is the
230
+ # right scope under execution_mode: :async — adapter and executor run in
231
+ # the same fiber during perform.
232
+ RETRY_REENQUEUED_KEY = :pgbus_batch_retry_reenqueued_job_ids
233
+
234
+ def self.note_retry_reenqueued(job_id)
235
+ (Thread.current[RETRY_REENQUEUED_KEY] ||= Set.new) << job_id
236
+ end
237
+
238
+ def self.forget_retry_reenqueued(job_id)
239
+ Thread.current[RETRY_REENQUEUED_KEY]&.delete(job_id)
240
+ end
241
+
242
+ def self.retry_reenqueued?(job_id)
243
+ Thread.current[RETRY_REENQUEUED_KEY]&.include?(job_id) || false
244
+ end
245
+
246
+ def self.clear_retry_reenqueued
247
+ Thread.current[RETRY_REENQUEUED_KEY] = nil
248
+ end
249
+
250
+ # Reverse of track_enqueue for a job that will never run (discarded at
251
+ # enqueue time, or its send raised with no msg_id).
252
+ def self.untrack_enqueue(payload)
253
+ batch_id = payload[METADATA_KEY]
254
+ return unless batch_id
255
+
256
+ job_id = payload["job_id"]
257
+ migrated = executions_migrated?
258
+ BatchEntry.transaction do
259
+ BatchEntry.decrement_total_jobs!(batch_id)
260
+ BatchExecution.where(job_id: job_id).delete_all if migrated && job_id
261
+ end
262
+ end
263
+
264
+ def self.backfill_execution(payload, msg_id, queue_name)
265
+ return unless executions_migrated?
266
+ return unless payload && msg_id
267
+
268
+ job_id = payload["job_id"]
269
+ return unless job_id
270
+
271
+ BatchExecution.backfill!(job_id, msg_id: msg_id, queue_name: queue_name)
272
+ end
273
+
274
+ # Single-winner finish via execution-row absence. After a winning UPDATE,
275
+ # re-check exists? in a fresh statement (Postgres READ COMMITTED can let a
276
+ # blocked CAS win from a stale NOT EXISTS snapshot — solid_queue's finalize).
277
+ def self.try_finish!(batch_id)
278
+ result = BatchEntry.transaction do
279
+ updated = BatchEntry.finish_if_empty!(batch_id)
280
+ next { just_finished: false, record: BatchEntry.find_by(batch_id: batch_id) } unless updated.positive?
281
+
282
+ raise ActiveRecord::Rollback if BatchExecution.where(batch_id: batch_id).exists?
283
+
284
+ { just_finished: true, record: BatchEntry.find_by(batch_id: batch_id) }
285
+ end
286
+
287
+ return { just_finished: false, record: BatchEntry.find_by(batch_id: batch_id) } if result.nil?
288
+
289
+ result
290
+ end
291
+
292
+ def self.sweep_stalled(stalled_for: Pgbus.configuration.batch_stall_threshold, batch_size: 500, client: Pgbus.client)
293
+ Sweep.run(stalled_for: stalled_for, batch_size: batch_size, client: client)
109
294
  end
110
295
 
111
296
  class << self
112
297
  private
113
298
 
299
+ def resolve_execution(batch_id, job_id, column)
300
+ BatchEntry.transaction do
301
+ deleted = BatchExecution.where(job_id: job_id).delete_all
302
+ BatchEntry.increment_counter!(batch_id, column) if deleted.positive? || legacy_untracked_batch?(batch_id)
303
+ end
304
+ finish_if_needed(try_finish!(batch_id))
305
+ end
306
+
307
+ # A migrated batch with no execution rows at all is a pre-migration
308
+ # in-flight group. Increment counters (the executor no longer hits the
309
+ # discarded_jobs column) and let finish_if_empty! wait until they match.
310
+ def legacy_untracked_batch?(batch_id)
311
+ return false if BatchExecution.where(batch_id: batch_id).exists?
312
+
313
+ record = BatchEntry.find_by(batch_id: batch_id)
314
+ record && !counters_match_total?(record)
315
+ end
316
+
317
+ def counters_match_total?(record)
318
+ failures = record.respond_to?(:discarded_jobs) ? record.discarded_jobs.to_i : record.failed_jobs.to_i
319
+ record.total_jobs.positive? && (record.completed_jobs + failures) == record.total_jobs
320
+ end
321
+
322
+ def signal_without_row(batch_id, column)
323
+ update_counter(batch_id, column)
324
+ finish_if_needed(try_finish!(batch_id))
325
+ end
326
+
327
+ def finish_if_needed(result)
328
+ return result unless result&.fetch(:just_finished, false) && result[:record]
329
+
330
+ fire_callbacks(result[:record])
331
+ instrument_finished(result[:record])
332
+ result
333
+ end
334
+
335
+ def instrument_finished(record)
336
+ Instrumentation.instrument(
337
+ "pgbus.batch_finished",
338
+ batch_id: record.respond_to?(:batch_id) ? record.batch_id : nil,
339
+ total_jobs: record.respond_to?(:total_jobs) ? record.total_jobs : nil,
340
+ completed_jobs: record.respond_to?(:completed_jobs) ? record.completed_jobs : nil,
341
+ failed_jobs: failure_count(record)
342
+ )
343
+ end
344
+
345
+ def failure_count(record)
346
+ use_failed = record.respond_to?(:has_attribute?) &&
347
+ record.has_attribute?(:failed_jobs) &&
348
+ !record.has_attribute?(:discarded_jobs)
349
+ return record.failed_jobs.to_i if use_failed
350
+ return record.discarded_jobs.to_i if record.respond_to?(:discarded_jobs)
351
+
352
+ 0
353
+ end
354
+
114
355
  def update_counter(batch_id, column)
115
356
  result = BatchEntry.increment_counter!(batch_id, column)
116
357
  return nil unless result
117
358
 
118
- fire_callbacks(result[:record]) if result[:just_finished]
119
- result
359
+ finish_if_needed(result)
120
360
  end
121
361
 
122
362
  def fire_callbacks(record)
@@ -126,11 +366,56 @@ module Pgbus
126
366
  Pgbus.logger.error { "[Pgbus] Invalid batch properties JSON: #{e.message}" }
127
367
  {}
128
368
  end
129
- all_succeeded = record.discarded_jobs.zero?
369
+ all_succeeded = failure_count(record).to_i.zero?
370
+
371
+ fire_callback(record, :on_finish, properties)
372
+ fire_callback(record, :on_success, properties) if all_succeeded
373
+ fire_failure_callback(record, properties) unless all_succeeded
374
+ end
375
+
376
+ # A configured instance (jsonb column) wins over the legacy class-name
377
+ # column so an app that sets both gets the richer form.
378
+ def fire_callback(record, slot, properties)
379
+ job_data = callback_job_data(record, "#{slot}_job")
380
+ return enqueue_callback_instance(job_data, record.batch_id) if job_data
381
+
382
+ class_name = record.public_send("#{slot}_class")
383
+ enqueue_callback(class_name, properties) if class_name
384
+ end
385
+
386
+ def fire_failure_callback(record, properties)
387
+ job_data = callback_job_data(record, "on_failure_job")
388
+ return enqueue_callback_instance(job_data, record.batch_id) if job_data
130
389
 
131
- enqueue_callback(record.on_finish_class, properties) if record.on_finish_class
132
- enqueue_callback(record.on_success_class, properties) if record.on_success_class && all_succeeded
133
- enqueue_callback(record.on_discard_class, properties) if record.on_discard_class && !all_succeeded
390
+ failure_class = failure_callback_class(record)
391
+ enqueue_callback(failure_class, properties) if failure_class
392
+ end
393
+
394
+ def callback_job_data(record, column)
395
+ return nil unless record.respond_to?(column)
396
+
397
+ data = record.public_send(column)
398
+ data.presence
399
+ end
400
+
401
+ # Callbacks are never members of the batch they report on: batch_id is
402
+ # cleared and callback_batch_id points at the finished batch, so
403
+ # ActiveJob::Base#batch inside the callback reads that batch.
404
+ def enqueue_callback_instance(job_data, batch_id)
405
+ job = ::ActiveJob::Base.deserialize(job_data)
406
+ job.batch_id = nil if job.respond_to?(:batch_id=)
407
+ job.callback_batch_id = batch_id if job.respond_to?(:callback_batch_id=)
408
+ job.enqueue
409
+ rescue StandardError => e
410
+ Pgbus.logger.error { "[Pgbus] Batch callback job could not be enqueued: #{e.class}: #{e.message}" }
411
+ end
412
+
413
+ def failure_callback_class(record)
414
+ if record.respond_to?(:on_failure_class) && record.on_failure_class.present?
415
+ record.on_failure_class
416
+ elsif record.respond_to?(:on_discard_class)
417
+ record.on_discard_class
418
+ end
134
419
  end
135
420
 
136
421
  def enqueue_callback(class_name, properties)
@@ -142,5 +427,105 @@ module Pgbus
142
427
  job_class.perform_later(properties)
143
428
  end
144
429
  end
430
+
431
+ private
432
+
433
+ def initialize_from_record(record)
434
+ @record = record
435
+ @batch_id = record.batch_id
436
+ @description = record.description
437
+ @properties = parse_properties(record.properties)
438
+ @on_finish = nil
439
+ @on_success = nil
440
+ @on_failure = nil
441
+ @started = true
442
+ end
443
+
444
+ # Add to an already-created batch. Each job counts itself in (guarded
445
+ # increment + execution row, see .track_enqueue) as it is enqueued, so an
446
+ # add into a finished batch raises at perform_later before anything is
447
+ # sent; the fresh-read check here is only an early exit for the common
448
+ # case. check_finished! afterwards covers a block whose jobs all reached a
449
+ # terminal state while it was still open.
450
+ def reopen(&)
451
+ reload
452
+ raise AlreadyFinished, "Can't add jobs into an already finished batch" if finished?
453
+
454
+ count_jobs(&)
455
+ reload
456
+ self.class.send(:finish_if_needed, BatchEntry.check_finished!(batch_id))
457
+ self
458
+ end
459
+
460
+ def create_record
461
+ attrs = {
462
+ batch_id: batch_id,
463
+ description: description,
464
+ on_finish_class: callback_class_name(on_finish),
465
+ on_success_class: callback_class_name(on_success),
466
+ properties: JSON.generate(properties),
467
+ status: "pending"
468
+ }
469
+ if self.class.executions_migrated?
470
+ attrs[:on_failure_class] = callback_class_name(on_failure)
471
+ else
472
+ attrs[:on_discard_class] = callback_class_name(on_failure)
473
+ end
474
+ attrs.merge!(callback_job_attributes) if self.class.callback_jobs_migrated?
475
+ @record = BatchEntry.create!(attrs)
476
+ end
477
+
478
+ # A callback given as a bare class keeps the legacy *_class column; a
479
+ # configured ActiveJob instance is serialized now (so .set options resolve
480
+ # at creation, matching solid_queue) into the *_job jsonb column. Before
481
+ # the add_batch_callback_jobs migration there is nowhere to keep the
482
+ # instance, so it degrades to its class (the callback still fires, on its
483
+ # default queue) with a warning rather than being dropped.
484
+ def callback_class_name(callback)
485
+ return callback.name if callback.is_a?(Class)
486
+ return nil if callback.nil? || self.class.callback_jobs_migrated?
487
+
488
+ self.class.warn_callback_jobs_unmigrated
489
+ callback.class.name
490
+ end
491
+
492
+ def callback_job_attributes
493
+ {
494
+ on_finish_job: serialize_callback(on_finish),
495
+ on_success_job: serialize_callback(on_success),
496
+ on_failure_job: serialize_callback(on_failure)
497
+ }
498
+ end
499
+
500
+ def serialize_callback(callback)
501
+ return nil if callback.nil? || callback.is_a?(Class)
502
+
503
+ callback.serialize
504
+ end
505
+
506
+ def count_jobs(&)
507
+ previous_batch_id = Thread.current[:pgbus_batch_id]
508
+ Thread.current[:pgbus_batch_id] = batch_id
509
+ yield
510
+ ensure
511
+ Thread.current[:pgbus_batch_id] = previous_batch_id
512
+ end
513
+
514
+ # End of the first block: the jobs already counted themselves in, so only
515
+ # the status moves (guarded — the stalled-batch sweep may have flipped it
516
+ # already). An empty block leaves total_jobs = 0, which try_finish!
517
+ # closes through the same single-winner path as any other batch.
518
+ def start_processing
519
+ BatchEntry.where(batch_id: batch_id, status: "pending").update_all(status: "processing")
520
+ reload
521
+ self.class.send(:finish_if_needed, BatchEntry.check_finished!(batch_id))
522
+ end
523
+
524
+ def parse_properties(props)
525
+ JSON.parse(props.presence || "{}")
526
+ rescue JSON::ParserError => e
527
+ Pgbus.logger.error { "[Pgbus] Invalid batch properties JSON: #{e.message}" }
528
+ {}
529
+ end
145
530
  end
146
531
  end
@@ -24,8 +24,22 @@ module Pgbus
24
24
  # the next checkout after a disconnect reconnects transparently.
25
25
  def self.disconnect_all_pools!
26
26
  connection_handler.connection_pool_list(:all).each do |pool|
27
- pool.disconnect! if pool.connection_class == self
27
+ pool.disconnect! if owns_pool?(pool)
28
28
  end
29
29
  end
30
+
31
+ # Rails 8.0 replaced ConnectionPool#connection_class with
32
+ # #connection_descriptor — a ConnectionDescriptor whose #name is the
33
+ # owning class name (issue #411). Pools `connects_to` creates register
34
+ # under the class name, so the name comparison is equivalent to the
35
+ # class-identity check on 7.x. A nil descriptor (null pool) never matches.
36
+ def self.owns_pool?(pool)
37
+ if pool.respond_to?(:connection_descriptor)
38
+ pool.connection_descriptor&.name == name
39
+ else
40
+ pool.connection_class == self
41
+ end
42
+ end
43
+ private_class_method :owns_pool?
30
44
  end
31
45
  end