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
@@ -0,0 +1,13 @@
1
+ class AddPgbusBatchCallbackJobs < ActiveRecord::Migration<%= migration_version %>
2
+ def up
3
+ add_column :pgbus_batches, :on_finish_job, :jsonb unless column_exists?(:pgbus_batches, :on_finish_job)
4
+ add_column :pgbus_batches, :on_success_job, :jsonb unless column_exists?(:pgbus_batches, :on_success_job)
5
+ add_column :pgbus_batches, :on_failure_job, :jsonb unless column_exists?(:pgbus_batches, :on_failure_job)
6
+ end
7
+
8
+ def down
9
+ remove_column :pgbus_batches, :on_finish_job if column_exists?(:pgbus_batches, :on_finish_job)
10
+ remove_column :pgbus_batches, :on_success_job if column_exists?(:pgbus_batches, :on_success_job)
11
+ remove_column :pgbus_batches, :on_failure_job if column_exists?(:pgbus_batches, :on_failure_job)
12
+ end
13
+ end
@@ -0,0 +1,50 @@
1
+ class AddPgbusBatchExecutions < ActiveRecord::Migration<%= migration_version %>
2
+ def up
3
+ create_table :pgbus_batch_executions do |t|
4
+ t.string :batch_id, null: false
5
+ t.string :job_id, null: false
6
+ t.bigint :msg_id
7
+ t.string :queue_name
8
+ t.datetime :created_at, null: false, default: -> { "CURRENT_TIMESTAMP" }
9
+ end
10
+
11
+ add_index :pgbus_batch_executions, :job_id,
12
+ unique: true, name: "idx_pgbus_batch_executions_job_id"
13
+ add_index :pgbus_batch_executions, :batch_id,
14
+ name: "idx_pgbus_batch_executions_batch_id"
15
+ add_index :pgbus_batch_executions, :created_at,
16
+ where: "msg_id IS NULL",
17
+ name: "idx_pgbus_batch_executions_orphans"
18
+ add_foreign_key :pgbus_batch_executions, :pgbus_batches,
19
+ column: :batch_id, primary_key: :batch_id, on_delete: :cascade
20
+
21
+ if column_exists?(:pgbus_batches, :on_discard_class) && !column_exists?(:pgbus_batches, :on_failure_class)
22
+ rename_column :pgbus_batches, :on_discard_class, :on_failure_class
23
+ end
24
+
25
+ # Drain/restart pgbus workers after this migration: a process that still
26
+ # writes discarded_jobs will error once the column is gone.
27
+ if column_exists?(:pgbus_batches, :discarded_jobs)
28
+ if column_exists?(:pgbus_batches, :failed_jobs)
29
+ execute "UPDATE pgbus_batches SET failed_jobs = failed_jobs + discarded_jobs"
30
+ remove_column :pgbus_batches, :discarded_jobs
31
+ else
32
+ rename_column :pgbus_batches, :discarded_jobs, :failed_jobs
33
+ end
34
+ end
35
+ end
36
+
37
+ def down
38
+ unless column_exists?(:pgbus_batches, :discarded_jobs)
39
+ add_column :pgbus_batches, :discarded_jobs, :integer, null: false, default: 0
40
+ if column_exists?(:pgbus_batches, :failed_jobs)
41
+ execute "UPDATE pgbus_batches SET discarded_jobs = failed_jobs"
42
+ execute "UPDATE pgbus_batches SET failed_jobs = 0"
43
+ end
44
+ end
45
+ if column_exists?(:pgbus_batches, :on_failure_class) && !column_exists?(:pgbus_batches, :on_discard_class)
46
+ rename_column :pgbus_batches, :on_failure_class, :on_discard_class
47
+ end
48
+ drop_table :pgbus_batch_executions if table_exists?(:pgbus_batch_executions)
49
+ end
50
+ end
@@ -44,6 +44,8 @@ Pgbus.configure do |c|
44
44
 
45
45
  # --- Dispatcher (maintenance tasks) ------------------------------------
46
46
  # c.dispatch_interval = 1.0
47
+ # c.batch_retention = 7.days # finished-batch cleanup; nil disables
48
+ # c.batch_sweep_interval = 5.minutes # stalled-batch repair sweep
47
49
 
48
50
  # --- Realtime broadcast isolation (turbo-rails) ------------------------
49
51
  # Route turbo-rails' async render+broadcast jobs to a dedicated queue so a
@@ -104,12 +104,14 @@ class CreatePgbusTables < ActiveRecord::Migration<%= migration_version %>
104
104
  t.string :description
105
105
  t.string :on_finish_class
106
106
  t.string :on_success_class
107
- t.string :on_discard_class
107
+ t.string :on_failure_class
108
+ t.jsonb :on_finish_job
109
+ t.jsonb :on_success_job
110
+ t.jsonb :on_failure_job
108
111
  t.jsonb :properties, default: {}
109
112
  t.integer :total_jobs, null: false, default: 0
110
113
  t.integer :completed_jobs, null: false, default: 0
111
114
  t.integer :failed_jobs, null: false, default: 0
112
- t.integer :discarded_jobs, null: false, default: 0
113
115
  t.string :status, null: false, default: "pending"
114
116
  t.datetime :created_at, null: false, default: -> { "CURRENT_TIMESTAMP" }
115
117
  t.datetime :finished_at
@@ -120,6 +122,24 @@ class CreatePgbusTables < ActiveRecord::Migration<%= migration_version %>
120
122
  add_index :pgbus_batches, :status,
121
123
  name: "idx_pgbus_batches_status"
122
124
 
125
+ create_table :pgbus_batch_executions do |t|
126
+ t.string :batch_id, null: false
127
+ t.string :job_id, null: false
128
+ t.bigint :msg_id
129
+ t.string :queue_name
130
+ t.datetime :created_at, null: false, default: -> { "CURRENT_TIMESTAMP" }
131
+ end
132
+
133
+ add_index :pgbus_batch_executions, :job_id,
134
+ unique: true, name: "idx_pgbus_batch_executions_job_id"
135
+ add_index :pgbus_batch_executions, :batch_id,
136
+ name: "idx_pgbus_batch_executions_batch_id"
137
+ add_index :pgbus_batch_executions, :created_at,
138
+ where: "msg_id IS NULL",
139
+ name: "idx_pgbus_batch_executions_orphans"
140
+ add_foreign_key :pgbus_batch_executions, :pgbus_batches,
141
+ column: :batch_id, primary_key: :batch_id, on_delete: :cascade
142
+
123
143
  # Recurring task definitions (synced from config/recurring.yml)
124
144
  create_table :pgbus_recurring_tasks do |t|
125
145
  t.string :key, null: false
@@ -186,6 +206,7 @@ class CreatePgbusTables < ActiveRecord::Migration<%= migration_version %>
186
206
  drop_table :pgbus_stream_queues
187
207
  drop_table :pgbus_recurring_executions
188
208
  drop_table :pgbus_recurring_tasks
209
+ drop_table :pgbus_batch_executions
189
210
  drop_table :pgbus_batches
190
211
  drop_table :pgbus_blocked_executions
191
212
  drop_table :pgbus_semaphores
@@ -10,9 +10,12 @@ module Pgbus
10
10
  payload_hash = Serializer.serialize_job_hash(active_job)
11
11
  payload_hash = Concurrency.inject_metadata(active_job, payload_hash)
12
12
  payload_hash = Uniqueness.inject_metadata(active_job, payload_hash)
13
- payload_hash = inject_batch_metadata(payload_hash)
13
+ payload_hash = inject_batch_metadata(payload_hash, active_job: active_job)
14
14
 
15
- return active_job if uniqueness_rejected?(active_job, payload_hash)
15
+ if uniqueness_rejected?(active_job, payload_hash, queue: queue)
16
+ uncount_batch_job(payload_hash)
17
+ return active_job
18
+ end
16
19
 
17
20
  enqueue_with_concurrency(active_job, queue, payload_hash)
18
21
  end
@@ -22,18 +25,22 @@ module Pgbus
22
25
  payload_hash = Serializer.serialize_job_hash(active_job)
23
26
  payload_hash = Concurrency.inject_metadata(active_job, payload_hash)
24
27
  payload_hash = Uniqueness.inject_metadata(active_job, payload_hash)
25
- payload_hash = inject_batch_metadata(payload_hash)
28
+ payload_hash = inject_batch_metadata(payload_hash, active_job: active_job)
26
29
  delay = [(timestamp - Time.current.to_f).ceil, 0].max
27
30
 
28
- return active_job if uniqueness_rejected?(active_job, payload_hash)
31
+ if uniqueness_rejected?(active_job, payload_hash, queue: queue)
32
+ uncount_batch_job(payload_hash)
33
+ return active_job
34
+ end
29
35
 
30
36
  enqueue_with_concurrency(active_job, queue, payload_hash, delay: delay)
31
37
  end
32
38
 
33
39
  def enqueue_all(active_jobs)
34
- # Jobs with uniqueness must go through individual enqueue to acquire locks
35
- unique, bulk = active_jobs.partition { |j| Uniqueness.uniqueness_config(j) }
36
- unique.each do |j|
40
+ # Jobs with uniqueness or concurrency must go through individual enqueue
41
+ # to acquire locks/semaphores the bulk path cannot (issue #413)
42
+ individual, bulk = active_jobs.partition { |j| Uniqueness.uniqueness_config(j) || concurrency_config(j) }
43
+ individual.each do |j|
37
44
  if scheduled_in_future?(j)
38
45
  enqueue_at(j, j.scheduled_at.to_f)
39
46
  else
@@ -41,9 +48,12 @@ module Pgbus
41
48
  end
42
49
  end
43
50
 
44
- bulk.group_by { |j| j.queue_name || Pgbus.configuration.default_queue }.each do |queue, jobs|
51
+ # Group by priority too: send_batch routes through the queue strategy,
52
+ # so a mixed-priority bulk send needs one produce_batch per level.
53
+ bulk.group_by { |j| [j.queue_name || Pgbus.configuration.default_queue, j.try(:priority)] }
54
+ .each do |(queue, priority), jobs|
45
55
  immediate, scheduled = jobs.partition { |j| !scheduled_in_future?(j) }
46
- enqueue_immediate(queue, immediate)
56
+ enqueue_immediate(queue, immediate, priority: priority)
47
57
  scheduled.each { |j| enqueue_at(j, j.scheduled_at.to_f) }
48
58
  end
49
59
 
@@ -56,6 +66,8 @@ module Pgbus
56
66
  key = Concurrency.extract_key(payload_hash)
57
67
  concurrency = concurrency_config(active_job)
58
68
  priority = active_job.try(:priority)
69
+ msg_id = nil
70
+ blocked = false
59
71
 
60
72
  if key && concurrency
61
73
  result = Concurrency::Semaphore.acquire(key, concurrency[:limit], concurrency[:duration])
@@ -64,34 +76,48 @@ module Pgbus
64
76
  msg_id = Pgbus.client.send_message(queue, payload_hash, delay: delay, priority: priority)
65
77
  active_job.provider_job_id = msg_id
66
78
  else
67
- handle_conflict(concurrency, active_job, key, queue, payload_hash, priority: priority)
79
+ blocked = handle_conflict(concurrency, active_job, key, queue, payload_hash, priority: priority)
68
80
  end
69
81
  else
70
82
  msg_id = Pgbus.client.send_message(queue, payload_hash, delay: delay, priority: priority)
71
83
  active_job.provider_job_id = msg_id
72
84
  end
73
85
 
86
+ # Bind before backfill so a live message is never left with an unbound
87
+ # uniqueness row if execution-row bookkeeping raises.
88
+ bind_acquired_uniqueness_lock(queue, msg_id) if msg_id
89
+ Batch.backfill_execution(payload_hash, msg_id, physical_queue(queue, priority)) if msg_id
90
+ # A retry re-enqueue that is now live (sent, or parked as a blocked
91
+ # execution) must stop the original attempt from signalling completion.
92
+ Batch.note_retry_reenqueued(payload_hash["job_id"]) if (msg_id || blocked) && retry_retagged?(payload_hash)
93
+ uniqueness_key = Thread.current[:pgbus_acquired_uniqueness_key]
94
+ UniquenessKey.clear_bind_stamp!(uniqueness_key) if uniqueness_key
74
95
  Thread.current[:pgbus_acquired_uniqueness_key] = nil
75
96
  active_job
76
97
  rescue StandardError => e
77
- # Roll back the uniqueness lock if enqueue failed
78
- rollback_key = Thread.current[:pgbus_acquired_uniqueness_key]
79
- if rollback_key
80
- begin
81
- Uniqueness.release_lock(rollback_key)
82
- rescue StandardError => rollback_error
83
- Pgbus.logger.warn { "[Pgbus] Lock rollback failed: #{rollback_error.message}" }
84
- end
98
+ if msg_id.nil?
99
+ rollback_acquired_uniqueness_lock
100
+ uncount_batch_job(payload_hash)
101
+ else
102
+ # Message is live: drop the thread-local so a later discard on this
103
+ # thread cannot release that job's uniqueness lock, but do not
104
+ # DELETE the pgbus_uniqueness_keys row.
85
105
  Thread.current[:pgbus_acquired_uniqueness_key] = nil
86
106
  end
87
107
  raise e
88
108
  end
89
109
 
110
+ def physical_queue(queue, priority)
111
+ Pgbus.client.target_queue(queue, priority)
112
+ end
113
+
90
114
  def concurrency_config(active_job)
91
115
  active_job.class.respond_to?(:pgbus_concurrency) && active_job.class.pgbus_concurrency
92
116
  end
93
117
 
94
- def handle_conflict(concurrency, active_job, key, queue, payload_hash, priority: nil)
118
+ # Returns true when the job was parked as a blocked execution (it will
119
+ # run later), false when it was dropped.
120
+ def handle_conflict(concurrency, active_job, key, queue, payload_hash, priority: nil) # rubocop:disable Naming/PredicateMethod
95
121
  case concurrency[:on_conflict]
96
122
  when :block
97
123
  Concurrency::BlockedExecution.insert(
@@ -101,14 +127,37 @@ module Pgbus
101
127
  priority: priority || Pgbus.configuration.default_priority,
102
128
  duration: concurrency[:duration]
103
129
  )
130
+ return true
104
131
  when :discard
105
132
  Pgbus.logger.info { "[Pgbus] Discarding job #{active_job.class.name}: concurrency limit for #{key}" }
133
+ # The job will never run: roll back an :until_executed uniqueness lock
134
+ # acquired earlier in this enqueue (no executor will release it), and
135
+ # uncount it from its batch so completion is not waited on forever.
136
+ rollback_acquired_uniqueness_lock
137
+ uncount_batch_job(payload_hash)
106
138
  when :raise
107
139
  raise ConcurrencyLimitExceeded, "Concurrency limit reached for key: #{key}"
108
140
  end
141
+ false
109
142
  end
110
143
 
111
- def uniqueness_rejected?(active_job, payload_hash)
144
+ # Releases an :until_executed lock this enqueue acquired, if any.
145
+ # Used when the job is dropped before a message is sent (concurrency
146
+ # :discard, or send_message raising) — otherwise the lock is orphaned
147
+ # because no executor will ever release it.
148
+ def rollback_acquired_uniqueness_lock
149
+ rollback_key = Thread.current[:pgbus_acquired_uniqueness_key]
150
+ return unless rollback_key
151
+
152
+ begin
153
+ Uniqueness.release_lock(rollback_key)
154
+ rescue StandardError => e
155
+ Pgbus.logger.warn { "[Pgbus] Lock rollback failed: #{e.message}" }
156
+ end
157
+ Thread.current[:pgbus_acquired_uniqueness_key] = nil
158
+ end
159
+
160
+ def uniqueness_rejected?(active_job, payload_hash, queue:)
112
161
  uniqueness_key = Uniqueness.extract_key(payload_hash)
113
162
  return false unless uniqueness_key
114
163
 
@@ -123,7 +172,7 @@ module Pgbus
123
172
  # See issue #333.
124
173
  return false if active_job.executions.to_i.positive?
125
174
 
126
- result = Uniqueness.acquire_enqueue_lock(uniqueness_key, active_job)
175
+ result = Uniqueness.acquire_enqueue_lock(uniqueness_key, active_job, queue_name: queue)
127
176
 
128
177
  # :no_lock means no enqueue-time lock needed (e.g. :while_executing strategy)
129
178
  return false if result == :no_lock
@@ -147,25 +196,90 @@ module Pgbus
147
196
  end
148
197
  end
149
198
 
150
- def inject_batch_metadata(payload_hash)
199
+ def bind_acquired_uniqueness_lock(queue, msg_id)
200
+ key = Thread.current[:pgbus_acquired_uniqueness_key]
201
+ return unless key
202
+
203
+ Uniqueness.bind_lock(key, queue_name: queue, msg_id: msg_id)
204
+ rescue StandardError => e
205
+ Pgbus.logger.warn { "[Pgbus] Uniqueness bind failed: #{e.message}" }
206
+ end
207
+
208
+ # Reverses inject_batch_metadata for a job discarded at enqueue time
209
+ # (uniqueness duplicate or concurrency :discard conflict): the message is
210
+ # never sent, so it can never signal completion. Only applies while the
211
+ # tagging batch's block is still active on this thread.
212
+ def uncount_batch_job(payload_hash)
213
+ batch_id = payload_hash[Batch::METADATA_KEY]
214
+ return unless batch_id
215
+
216
+ if batch_id == Thread.current[:pgbus_batch_id]
217
+ Batch.untrack_enqueue(payload_hash)
218
+ elsif retry_retagged?(payload_hash)
219
+ # The retry never became live; the original attempt's row and its
220
+ # normal completion signal stand.
221
+ Batch.forget_retry_reenqueued(payload_hash["job_id"])
222
+ end
223
+ end
224
+
225
+ # Tagged for a batch while no Batch#enqueue block is active on this
226
+ # thread — only a retry_on re-enqueue gets there (issue #424).
227
+ def retry_retagged?(payload_hash)
228
+ payload_hash[Batch::METADATA_KEY] && Thread.current[:pgbus_batch_id].nil?
229
+ end
230
+
231
+ # A retry_on re-enqueue of a job that is already a batch member: same
232
+ # job_id (executions > 0), batch_id carried by the BatchId mixin. It
233
+ # rejoins its batch without being counted again. A first-attempt job
234
+ # that merely has a batch_id outside a block is NOT tagged — membership
235
+ # stays explicit; only the callback_batch_id never re-tags.
236
+ def retry_batch_id_for(active_job)
237
+ return nil unless active_job.respond_to?(:batch_id)
238
+ return nil unless active_job.executions.to_i.positive?
239
+
240
+ active_job.batch_id
241
+ end
242
+
243
+ # Tag the payload with the active batch and count it in (guarded
244
+ # increment + execution row, before the send — Batch.track_enqueue). Pass
245
+ # track: false to only tag, when the caller counts a bulk once.
246
+ def inject_batch_metadata(payload_hash, active_job: nil, track: true)
151
247
  batch_id = Thread.current[:pgbus_batch_id]
152
- return payload_hash unless batch_id
248
+ if batch_id
249
+ tagged = payload_hash.merge(Batch::METADATA_KEY => batch_id)
250
+ Batch.track_enqueue(tagged) if track
251
+ return tagged
252
+ end
153
253
 
154
- Thread.current[:pgbus_batch_job_count] = (Thread.current[:pgbus_batch_job_count] || 0) + 1
155
- payload_hash.merge(Batch::METADATA_KEY => batch_id)
254
+ retry_batch_id = active_job && retry_batch_id_for(active_job)
255
+ return payload_hash unless retry_batch_id
256
+
257
+ tagged = payload_hash.merge(Batch::METADATA_KEY => retry_batch_id)
258
+ Batch.track_retry(tagged)
259
+ tagged
156
260
  end
157
261
 
158
- def enqueue_immediate(queue, jobs)
262
+ def enqueue_immediate(queue, jobs, priority: nil)
159
263
  return if jobs.empty?
160
264
 
161
- payloads = jobs.map { |j| Serializer.serialize_job_hash(j) }
162
- msg_ids = Pgbus.client.send_batch(queue, payloads)
265
+ payloads = jobs.map { |j| inject_batch_metadata(Serializer.serialize_job_hash(j), track: false) }
266
+ # One guarded increment for the whole bulk, not one per job.
267
+ Batch.track_enqueue(payloads) if Thread.current[:pgbus_batch_id]
268
+ physical = physical_queue(queue, priority)
269
+ msg_ids = nil
270
+ msg_ids = Pgbus.client.send_batch(queue, payloads, priority: priority)
163
271
 
164
272
  unless msg_ids.is_a?(Array) && msg_ids.size == jobs.size
165
273
  raise Pgbus::EnqueueError, "Pgbus batch enqueue failed: expected #{jobs.size} ids, got #{msg_ids&.size || 0}"
166
274
  end
167
275
 
168
276
  jobs.zip(msg_ids).each { |job, id| job.provider_job_id = id }
277
+ payloads.zip(msg_ids).each { |payload, id| Batch.backfill_execution(payload, id, physical) }
278
+ rescue Pgbus::EnqueueError
279
+ Array(payloads).each_with_index do |payload, index|
280
+ Batch.untrack_enqueue(payload) if msg_ids.nil? || msg_ids[index].nil?
281
+ end
282
+ raise
169
283
  rescue Pgbus::SchemaNotReady => e
170
284
  Pgbus.logger.error { "[Pgbus] #{e.message}" }
171
285
  raise
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module ActiveJob
5
+ # Gives every ActiveJob a handle on the batch it belongs to.
6
+ #
7
+ # Two distinct ids, mirroring solid_queue's ActiveJob::BatchId:
8
+ #
9
+ # * +batch_id+ — the batch this job is a *member* of. Assigned by the
10
+ # executor from the payload's +pgbus_batch_id+ before +perform+, so a
11
+ # running job can call +batch.enqueue+ to add siblings.
12
+ # * +callback_batch_id+ — the batch this job *reports on*. Set on
13
+ # +on_finish+/+on_success+/+on_failure+ jobs at fire time. A callback is
14
+ # never a member of the batch it reports on, so its +batch_id+ is nil.
15
+ #
16
+ # Both round-trip through +serialize+/+deserialize+, and both are omitted
17
+ # from the serialized hash when unset — an unbatched job's payload is
18
+ # byte-for-byte what it was before this mixin existed.
19
+ module BatchId
20
+ extend ActiveSupport::Concern
21
+
22
+ included do
23
+ attr_accessor :batch_id, :callback_batch_id
24
+ end
25
+
26
+ def serialize
27
+ data = super
28
+ data["batch_id"] = batch_id if batch_id
29
+ data["callback_batch_id"] = callback_batch_id if callback_batch_id
30
+ data
31
+ end
32
+
33
+ def deserialize(job_data)
34
+ super
35
+ self.batch_id = job_data["batch_id"]
36
+ self.callback_batch_id = job_data["callback_batch_id"]
37
+ end
38
+
39
+ # The batch this job reports on, or is a member of. nil when neither.
40
+ def batch
41
+ return @batch if defined?(@batch)
42
+
43
+ id = callback_batch_id || batch_id
44
+ @batch = id && Pgbus::Batch.find(id)
45
+ end
46
+ end
47
+ end
48
+ end
@@ -56,9 +56,14 @@ module Pgbus
56
56
  # released on completion or DLQ.
57
57
  nil
58
58
  when :while_executing
59
- # Acquire the lock now. If another worker is already executing
60
- # this job, skip it — VT will expire and it'll be retried.
61
- unless Uniqueness.acquire_execution_lock(uniqueness_key, payload)
59
+ # Acquire the lock now, bound to this message. If another worker is
60
+ # already executing this job, skip it — VT will expire and it'll be
61
+ # retried. A row left by a crashed attempt of THIS message is
62
+ # re-acquired, not treated as a duplicate.
63
+ acquired = Uniqueness.acquire_execution_lock(
64
+ uniqueness_key, payload, msg_id: message.msg_id.to_i, queue_name: queue_name
65
+ )
66
+ unless acquired
62
67
  Pgbus.logger.info { "[Pgbus] Skipping duplicate execution for #{job_class}" }
63
68
  return :skipped
64
69
  end
@@ -67,6 +72,7 @@ module Pgbus
67
72
 
68
73
  Pgbus.logger.debug { "[Pgbus::Executor] deserialized #{tag} job_class=#{job_class}" }
69
74
  job_succeeded = false
75
+ retried = false
70
76
 
71
77
  msg_id = message.msg_id.to_i
72
78
  instrument_payload = {
@@ -85,13 +91,22 @@ module Pgbus
85
91
  # (issue #368). Pass this executor's config so an injected allowlist
86
92
  # is not silently ignored in favour of Pgbus.configuration.
87
93
  job = Serializer.deserialize_job_data(payload, configuration: config)
94
+ # Batch membership rides the pgbus metadata key, not the serialized
95
+ # job data, so hand it to the job before perform — that is what makes
96
+ # `batch` (and `batch.enqueue` for open batches) work inside a job.
97
+ assign_batch_id(job, payload)
88
98
  Pgbus.logger.debug { "[Pgbus::Executor] running #{tag} job_class=#{job_class}" }
89
99
  execute_job(job)
100
+ # retry_on re-enqueues from inside perform_now and returns normally:
101
+ # this attempt is done (archive it) but the job is not — the retry
102
+ # message carries the batch tag and signals on its own outcome.
103
+ retried = Batch.retry_reenqueued?(payload["job_id"])
90
104
  Pgbus.logger.debug { "[Pgbus::Executor] perform_returned #{tag} job_class=#{job_class}" }
91
105
  archive_from(queue_name, msg_id, source_queue: source_queue)
92
106
  Pgbus.logger.debug { "[Pgbus::Executor] archived #{tag} job_class=#{job_class}" }
93
- FailedEventRecorder.clear!(queue_name: queue_name, msg_id: msg_id)
94
107
  job_succeeded = true
108
+ release_uniqueness_lock(uniqueness_key)
109
+ FailedEventRecorder.clear!(queue_name: queue_name, msg_id: msg_id)
95
110
  end
96
111
 
97
112
  instrument("pgbus.job_completed", queue: queue_name, job_class: job_class)
@@ -108,6 +123,10 @@ module Pgbus
108
123
  # silently lost control flow — no failed event row, no job_failed
109
124
  # notification, uniqueness lock held until VT expired. See issue #126.
110
125
  handle_failure(message, queue_name, e, payload: payload)
126
+ # A failed :while_executing attempt is no longer executing — release
127
+ # so the retry (same message, after VT) can acquire. :until_executed
128
+ # keeps its lock until success/DLQ by design (#126, #333).
129
+ release_uniqueness_lock(uniqueness_key) if uniqueness_strategy == :while_executing
111
130
  instrument(
112
131
  "pgbus.job_failed",
113
132
  queue: queue_name,
@@ -129,15 +148,32 @@ module Pgbus
129
148
  # job_succeeded is set AFTER archive_message, so if archive fails the
130
149
  # semaphore slot stays held until VT expires and the job is retried.
131
150
  if job_succeeded
151
+ # Uniqueness is released once, immediately after archive. A second
152
+ # key-only DELETE here can drop a successor that acquired the same
153
+ # key if the first DELETE committed but the client raised afterward.
132
154
  signal_concurrency(payload)
133
- signal_batch_completed(payload)
134
- # Release uniqueness lock on successful completion (both strategies)
135
- Uniqueness.release_lock(uniqueness_key) if uniqueness_key
155
+ signal_batch_completed(payload) unless retried
136
156
  end
157
+ Batch.clear_retry_reenqueued
137
158
  end
138
159
 
139
160
  private
140
161
 
162
+ def assign_batch_id(job, payload)
163
+ batch_id = payload[Batch::METADATA_KEY]
164
+ return unless batch_id && job.respond_to?(:batch_id=)
165
+
166
+ job.batch_id = batch_id
167
+ end
168
+
169
+ def release_uniqueness_lock(key)
170
+ return unless key
171
+
172
+ Uniqueness.release_lock(key)
173
+ rescue StandardError => e
174
+ Pgbus.logger.warn { "[Pgbus] Uniqueness release failed: #{e.message}" }
175
+ end
176
+
141
177
  def execute_job(job)
142
178
  if defined?(Rails) && Rails.respond_to?(:application) && Rails.application
143
179
  wrapper = reloading? ? Rails.application.reloader : Rails.application.executor
@@ -286,7 +322,7 @@ module Pgbus
286
322
  batch_id = payload[Batch::METADATA_KEY]
287
323
  return unless batch_id
288
324
 
289
- Batch.job_completed(batch_id)
325
+ Batch.job_completed(batch_id, job_id: payload["job_id"])
290
326
  rescue StandardError => e
291
327
  Pgbus.logger.warn { "[Pgbus] Batch completion signal failed: #{e.message}" }
292
328
  end
@@ -295,7 +331,7 @@ module Pgbus
295
331
  batch_id = payload[Batch::METADATA_KEY]
296
332
  return unless batch_id
297
333
 
298
- Batch.job_discarded(batch_id)
334
+ Batch.job_discarded(batch_id, job_id: payload["job_id"])
299
335
  rescue StandardError => e
300
336
  Pgbus.logger.warn { "[Pgbus] Batch discard signal failed: #{e.message}" }
301
337
  end