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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +30 -0
- data/app/models/pgbus/batch_entry.rb +78 -6
- data/app/models/pgbus/batch_execution.rb +28 -0
- data/app/models/pgbus/blocked_execution.rb +2 -2
- data/app/models/pgbus/uniqueness_key.rb +50 -4
- data/app/views/pgbus/batches/_batches_table.html.erb +3 -3
- data/app/views/pgbus/batches/show.html.erb +7 -7
- data/config/locales/da.yml +2 -2
- data/config/locales/de.yml +2 -2
- data/config/locales/en.yml +2 -2
- data/config/locales/es.yml +2 -2
- data/config/locales/fi.yml +2 -2
- data/config/locales/fr.yml +2 -2
- data/config/locales/it.yml +2 -2
- data/config/locales/ja.yml +2 -2
- data/config/locales/nb.yml +2 -2
- data/config/locales/nl.yml +2 -2
- data/config/locales/pt.yml +2 -2
- data/config/locales/sv.yml +2 -2
- data/lib/generators/pgbus/add_batch_callback_jobs_generator.rb +47 -0
- data/lib/generators/pgbus/add_batch_executions_generator.rb +44 -0
- data/lib/generators/pgbus/templates/add_batch_callback_jobs.rb.erb +13 -0
- data/lib/generators/pgbus/templates/add_batch_executions.rb.erb +50 -0
- data/lib/generators/pgbus/templates/initializer.rb.erb +2 -0
- data/lib/generators/pgbus/templates/migration.rb.erb +23 -2
- data/lib/pgbus/active_job/adapter.rb +142 -28
- data/lib/pgbus/active_job/batch_id.rb +48 -0
- data/lib/pgbus/active_job/executor.rb +45 -9
- data/lib/pgbus/batch/sweep.rb +163 -0
- data/lib/pgbus/batch.rb +448 -63
- data/lib/pgbus/bus_record.rb +15 -1
- data/lib/pgbus/client.rb +191 -15
- data/lib/pgbus/concurrency/blocked_execution.rb +14 -1
- data/lib/pgbus/configuration.rb +24 -1
- data/lib/pgbus/engine.rb +1 -0
- data/lib/pgbus/generators/migration_detector.rb +30 -0
- data/lib/pgbus/instrumentation.rb +5 -0
- data/lib/pgbus/outbox/poller.rb +6 -7
- data/lib/pgbus/process/dispatcher.rb +41 -17
- data/lib/pgbus/recurring/schedule.rb +12 -1
- data/lib/pgbus/uniqueness.rb +35 -7
- data/lib/pgbus/version.rb +1 -1
- data/lib/pgbus/web/data_source.rb +47 -9
- 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, :
|
|
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
|
-
@
|
|
31
|
+
@on_failure = on_failure || on_discard
|
|
18
32
|
@description = description
|
|
19
33
|
@properties = properties
|
|
20
|
-
@
|
|
34
|
+
@started = false
|
|
21
35
|
end
|
|
22
36
|
|
|
23
|
-
# Enqueue a group of jobs as a batch.
|
|
24
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
38
|
-
def self.job_discarded(batch_id)
|
|
39
|
-
|
|
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
|
|
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)
|
|
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
|
-
|
|
137
|
+
def self.executions_migrated?
|
|
138
|
+
return true if @executions_migrated
|
|
53
139
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
67
|
-
|
|
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
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
132
|
-
enqueue_callback(
|
|
133
|
-
|
|
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
|
data/lib/pgbus/bus_record.rb
CHANGED
|
@@ -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
|
|
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
|