pgbus 0.19.3 → 0.19.4

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: bd3bef8697d00eb6c47ce8c5d96c0c7780f8905015db0f3ffdfd963c23685c05
4
- data.tar.gz: 62484196117af7568316b85c3e90cda2c36a1ecb0d65de651b586593abb27fa8
3
+ metadata.gz: d5167e34401cdf1e4d452969bd1ebebd569e145bc559f62ea7d3913156e02ccf
4
+ data.tar.gz: 2f47167e2c3abc31d02482117db5cf0cf3d0c8b2e3413351864493782033503b
5
5
  SHA512:
6
- metadata.gz: 4319f21f8a8bf47371677468b81c4c99d44c0e00f59f62014d910a1912208cbd58d0410f6177d6d393f6ec0336a455e36351e4cd8ebb31d1dce242afca155b6a
7
- data.tar.gz: b0eb5972c1a2a7d0a2e6a8606b8e58911df19aeb759b817d1b96beb576d5785dd0851ebc9457a60546d7cf65ee76a58aac13f342ef49b6d744361bc16d15dfdd
6
+ metadata.gz: 936892796bbb42eb143e61bb6ae1e9f3ac4f1056127ddd96d1dd59638ba87f2c01b4c5e6fa26fa6a3b32e8d19b359070dceba20afd4ce91dd76edbbb98062efa
7
+ data.tar.gz: 30cfff6ead30c7b35d3454c56b41793b9f984c1283073c414e1e83b357325d5ab930160ecfa035b3cb9a6f2e1f1d832172d7684f8cdb467aa0354daa84273eaa
data/CHANGELOG.md CHANGED
@@ -13,6 +13,8 @@
13
13
 
14
14
  ### Fixed
15
15
 
16
+ - **`Pgbus.batch` inside a database transaction counts the jobs that enqueue after commit, and waits for them.** A job with Active Job's `enqueue_after_transaction_commit` added inside an open transaction is enqueued only when the transaction commits, after the block has returned and its thread tag is gone. The batch counted nothing, finished empty when the block returned and sent its `on_complete` (and `on_success`) callback before any member job existed; the jobs then ran outside the batch. Even a rolled-back transaction sent the callback. Now `b.add`, `batch.add` and `perform_later` record the batch on the job instance (`enqueue_batch_id`, transient: never serialized, cleared once the enqueue is done), the adapter tags and counts each job from it at the real enqueue, and the batch starts processing (or an open batch re-checks for finish) through `ActiveRecord.after_all_transactions_commit`, after every deferred enqueue has counted itself in. On a rollback nothing is sent, no callback fires and the batch row is rolled back with the transaction. A deferred first attempt discarded at enqueue (uniqueness or concurrency `on_conflict: :discard`) is uncounted as before, and a `retry_on` re-enqueue still re-joins without a second count. Outside a transaction nothing changes, including the queries a batch runs. Known gaps: a bare `ActiveJob.perform_all_later` inside the block does not record the batch (use `b.add`); a `batch.add` inside a transaction raises `AlreadyFinished` when the transaction commits, not at the `add` line, if the batch finished in the meantime; a batch row on a separate `connects_to` database is not part of the caller's transaction. Refs #553.
17
+ - **AppSignal: the job's transaction is current while the job runs, and carries its error (issue #552).** The AppSignal subscriber opened the `background_job` transaction from a finish-only notification callback, after the job had run, and completed it in the same call. `pgbus.job_failed` fired after that, so its `Appsignal.set_error` found no current transaction and reported nothing: a job whose class no longer exists (`ActiveJob::UnknownJobClassError`) or whose arguments cannot be deserialized retried and dead-lettered with nothing in AppSignal but the counters, an error raised inside `perform` arrived only as an action-less sample from the Rails error reporter, and job code calling `Appsignal.add_tags`, `set_error` or `instrument` attached to nothing. Event handlers had the same shape. The subscriber now listens to `pgbus.executor.execute` and `pgbus.event_processed` on both sides (`start` and `finish`): the `<JobClass>#perform` / `<Handler>#handle` transaction opens before the job or handler runs, is current for the whole execution (deserialize, perform, archive), records the exception the execution raised and completes at the end, so an error raised inside `perform` lands on it once, with its duration and queue start. A failure raised before that span opens (a message body that is not JSON, an event that cannot be built, a dead-letter move that failed) is reported from `pgbus.job_failed` / `pgbus.event_failed` on a transaction of its own with the same action and tags; an error the span already recorded is not reported twice. A transaction that is already current is reused and left to its owner to complete, as AppSignal's own ActiveJob hook does, and a span inside another of its kind restores the outer one when it finishes. Like that hook, the span records any exception that ends the execution, so a job interrupted between `perform` and the archive (an `Async::Stop` on shutdown under `execution_mode: :async`, an `Interrupt`) now shows up as an error sample too. The counters and the `pgbus_job_duration_ms` / `pgbus_event_duration_ms` distributions are recorded as before, once per execution. Refs #552.
16
18
  - **The dashboard's confirm and alert dialogs open centred again (issue #529).** The engine's stylesheet is built with Tailwind 4, whose preflight sets `margin: 0` on every element. A modal `<dialog>` keeps the browser's `inset: 0` but lost the `margin: auto` that centres it inside that inset, so every Pause, Purge and Delete confirmation opened in the top-left corner. Both dialogs now carry `m-auto`. The confirm dialog's Confirm button was white on yellow (1.91:1). It now uses the dark-on-yellow warning colours (`bg-yellow-400 text-yellow-950`), and the axe gate audits an open confirm dialog in light and dark mode. Refs #529.
17
19
 
18
20
  ## [0.19.0] - 2026-10-16
data/README.md CHANGED
@@ -917,13 +917,19 @@ By default, Pgbus logs caught exceptions and continues. To route them to your AP
917
917
  ```ruby
918
918
  Pgbus.configure do |c|
919
919
  c.error_reporters << ->(ex, ctx) {
920
- Appsignal.set_error(ex) { |t| t.set_tags(ctx) }
920
+ Appsignal.report_error(ex) do |t|
921
+ t.set_namespace("background_job") if ctx[:job_class]
922
+ t.set_action("#{ctx[:job_class]}#perform") if ctx[:job_class]
923
+ t.add_tags(ctx)
924
+ end
921
925
  }
922
926
  end
923
927
  ```
924
928
 
925
929
  Each reporter receives `(exception, context_hash)`. The context hash includes keys like `action`, `queue`, `job_class`, and `msg_id` depending on the call site. Reporters that accept a third argument also receive the Pgbus configuration object.
926
930
 
931
+ Use `Appsignal.report_error`, not `Appsignal.set_error`: a reporter may run from a rescue where no AppSignal transaction is current, and there `set_error` does nothing. `report_error` works whether a transaction is current or not. With the [AppSignal integration](#appsignal-integration) installed, job and handler errors already reach AppSignal on their own transaction, so a reporter like the one above is only needed for the other rescue paths. It does not report those job errors a second time: AppSignal marks an exception once it is on a transaction (`Transaction#add_error`) and does not add a marked exception to another one.
932
+
927
933
  Reporters are wired into all critical rescue paths: job execution failures, worker fetch/process errors, dispatcher maintenance, supervisor fork failures, circuit breaker trips, outbox publish errors, and failed event recording. Non-critical paths (dashboard queries, stat recording) remain log-only.
928
934
 
929
935
  `ErrorReporter.report` is guaranteed to never raise — if a reporter or the logger itself throws, the error is swallowed silently. This preserves fault-tolerance invariants at every rescue site.
@@ -951,7 +957,7 @@ If you upgraded from 0.9.x and previously did `rescue ArgumentError` around `Pgb
951
957
 
952
958
  When the `appsignal` gem is loaded in your app, Pgbus auto-installs a subscriber and a minutely probe that report into AppSignal:
953
959
 
954
- - **Background-job transactions** for every ActiveJob run and every event-bus handler invocation. Action names follow the AppSignal convention: `MyJob#perform`, `MyHandler#handle`. Tags include `queue`, `job_class`/`handler`, `routing_key`, `attempts`, and the `active_job_id` / `provider_job_id`. `enqueued_at` becomes the AppSignal `queue_start` timestamp so "time on queue" shows up correctly in the timeline.
960
+ - **Background-job transactions** for every ActiveJob run and every event-bus handler invocation. Action names follow the AppSignal convention: `MyJob#perform`, `MyHandler#handle`. Tags include `queue`, `job_class`/`handler`, `routing_key`, `attempts`, and the `active_job_id` / `provider_job_id`. `enqueued_at` becomes the AppSignal `queue_start` timestamp so "time on queue" shows up correctly in the timeline. The transaction is current for the whole execution (deserialize, `perform`/`handle`, archive), so `Appsignal.add_tags`, `Appsignal.set_error` and `Appsignal.instrument` called from job or handler code attach to it, and the error an execution raised is reported on it, also when it was raised before `perform` (an unknown job class, arguments that cannot be deserialized).
955
961
  - **Custom counters and distributions** for sends, reads, broadcasts, outbox publishes, recurring scheduling, and worker recycles. All metric names are prefixed `pgbus_`.
956
962
  - **A minutely probe** that gauges queue depth (visible vs total), oldest message age per queue, DLQ depth, failed events count, dead-tuple totals, MVCC horizon age, active processes, and stream connection estimates.
957
963
 
@@ -11,14 +11,16 @@ module Pgbus
11
11
  payload_hash = Concurrency.inject_metadata(active_job, payload_hash)
12
12
  payload_hash = Uniqueness.inject_metadata(active_job, payload_hash)
13
13
  payload_hash = FairShare.inject_metadata(active_job, payload_hash)
14
- payload_hash = inject_batch_metadata(payload_hash, active_job: active_job)
14
+ payload_hash = inject_batch_metadata(payload_hash, active_job)
15
15
 
16
16
  if uniqueness_rejected?(active_job, payload_hash, queue: queue)
17
- uncount_batch_job(payload_hash)
17
+ uncount_batch_job(payload_hash, active_job)
18
18
  return active_job
19
19
  end
20
20
 
21
21
  enqueue_with_concurrency(active_job, queue, payload_hash)
22
+ ensure
23
+ forget_enqueue_batch_id(active_job)
22
24
  end
23
25
 
24
26
  def enqueue_at(active_job, timestamp)
@@ -27,15 +29,17 @@ module Pgbus
27
29
  payload_hash = Concurrency.inject_metadata(active_job, payload_hash)
28
30
  payload_hash = Uniqueness.inject_metadata(active_job, payload_hash)
29
31
  payload_hash = FairShare.inject_metadata(active_job, payload_hash)
30
- payload_hash = inject_batch_metadata(payload_hash, active_job: active_job)
32
+ payload_hash = inject_batch_metadata(payload_hash, active_job)
31
33
  delay = [(timestamp - Time.current.to_f).ceil, 0].max
32
34
 
33
35
  if uniqueness_rejected?(active_job, payload_hash, queue: queue)
34
- uncount_batch_job(payload_hash)
36
+ uncount_batch_job(payload_hash, active_job)
35
37
  return active_job
36
38
  end
37
39
 
38
40
  enqueue_with_concurrency(active_job, queue, payload_hash, delay: delay)
41
+ ensure
42
+ forget_enqueue_batch_id(active_job)
39
43
  end
40
44
 
41
45
  def enqueue_all(active_jobs)
@@ -114,7 +118,7 @@ module Pgbus
114
118
  Batch.backfill_execution(payload_hash, msg_id, physical_queue(queue, priority)) if msg_id
115
119
  # A retry re-enqueue that is now live (sent, or parked as a blocked
116
120
  # execution) must stop the original attempt from signalling completion.
117
- Batch.note_retry_reenqueued(payload_hash["job_id"]) if (msg_id || blocked) && retry_retagged?(payload_hash)
121
+ Batch.note_retry_reenqueued(payload_hash["job_id"]) if (msg_id || blocked) && retry_retagged?(payload_hash, active_job)
118
122
  uniqueness_key = Thread.current[:pgbus_acquired_uniqueness_key]
119
123
  UniquenessKey.clear_bind_stamp!(uniqueness_key) if uniqueness_key
120
124
  Thread.current[:pgbus_acquired_uniqueness_key] = nil
@@ -131,7 +135,7 @@ module Pgbus
131
135
  # database error from the slot upsert or the park is not ambiguous.
132
136
  if msg_id.nil? && !(sending && ambiguous_delivery?(e))
133
137
  rollback_acquired_uniqueness_lock
134
- uncount_batch_job(payload_hash)
138
+ uncount_batch_job(payload_hash, active_job)
135
139
  else
136
140
  # Drop the thread-local so a later discard on this thread cannot
137
141
  # release that job's uniqueness lock, but do not DELETE the
@@ -210,7 +214,7 @@ module Pgbus
210
214
  # acquired earlier in this enqueue (no executor will release it), and
211
215
  # uncount it from its batch so completion is not waited on forever.
212
216
  rollback_acquired_uniqueness_lock
213
- uncount_batch_job(payload_hash)
217
+ uncount_batch_job(payload_hash, active_job)
214
218
  when :raise
215
219
  raise ConcurrencyLimitExceeded, "Concurrency limit reached for key: #{key}"
216
220
  end
@@ -283,25 +287,39 @@ module Pgbus
283
287
 
284
288
  # Reverses inject_batch_metadata for a job discarded at enqueue time
285
289
  # (uniqueness duplicate or concurrency :discard conflict): the message is
286
- # never sent, so it can never signal completion. Only applies while the
287
- # tagging batch's block is still active on this thread.
288
- def uncount_batch_job(payload_hash)
290
+ # never sent, so it can never signal completion. Only applies to a job
291
+ # counted in as a member (see #member_batch_id).
292
+ def uncount_batch_job(payload_hash, active_job)
289
293
  batch_id = payload_hash[Batch::METADATA_KEY]
290
294
  return unless batch_id
291
295
 
292
- if batch_id == Thread.current[:pgbus_batch_id]
296
+ if batch_id == member_batch_id(active_job)
293
297
  Batch.untrack_enqueue(payload_hash)
294
- elsif retry_retagged?(payload_hash)
298
+ elsif retry_retagged?(payload_hash, active_job)
295
299
  # The retry never became live; the original attempt's row and its
296
300
  # normal completion signal stand.
297
301
  Batch.forget_retry_reenqueued(payload_hash["job_id"])
298
302
  end
299
303
  end
300
304
 
301
- # Tagged for a batch while no Batch#enqueue block is active on this
302
- # thread — only a retry_on re-enqueue gets there (issue #424).
303
- def retry_retagged?(payload_hash)
304
- payload_hash[Batch::METADATA_KEY] && Thread.current[:pgbus_batch_id].nil?
305
+ # Tagged for a batch the job was neither added to nor enqueued inside
306
+ # the block of — only a retry_on re-enqueue gets there (issue #424).
307
+ def retry_retagged?(payload_hash, active_job)
308
+ payload_hash[Batch::METADATA_KEY] && member_batch_id(active_job).nil?
309
+ end
310
+
311
+ # The batch a job joins as a new member: the one it was added to, kept
312
+ # on the instance so it survives an enqueue deferred to after commit
313
+ # (issue #553), else the Batch#enqueue block active on this thread. The
314
+ # instance wins: a deferred enqueue can run while another batch's
315
+ # block is open on the committing thread.
316
+ def member_batch_id(active_job)
317
+ (active_job.enqueue_batch_id if active_job.respond_to?(:enqueue_batch_id)) ||
318
+ Thread.current[:pgbus_batch_id]
319
+ end
320
+
321
+ def forget_enqueue_batch_id(active_job)
322
+ active_job.enqueue_batch_id = nil if active_job.respond_to?(:enqueue_batch_id=)
305
323
  end
306
324
 
307
325
  # A retry_on re-enqueue of a job that is already a batch member: same
@@ -316,18 +334,17 @@ module Pgbus
316
334
  active_job.batch_id
317
335
  end
318
336
 
319
- # Tag the payload with the active batch and count it in (guarded
320
- # increment + execution row, before the send — Batch.track_enqueue). Pass
321
- # track: false to only tag, when the caller counts a bulk once.
322
- def inject_batch_metadata(payload_hash, active_job: nil, track: true)
323
- batch_id = Thread.current[:pgbus_batch_id]
337
+ # Tag the payload with the job's batch and count it in (guarded
338
+ # increment + execution row, before the send — Batch.track_enqueue).
339
+ def inject_batch_metadata(payload_hash, active_job)
340
+ batch_id = member_batch_id(active_job)
324
341
  if batch_id
325
342
  tagged = payload_hash.merge(Batch::METADATA_KEY => batch_id)
326
- Batch.track_enqueue(tagged) if track
343
+ Batch.track_enqueue(tagged)
327
344
  return tagged
328
345
  end
329
346
 
330
- retry_batch_id = active_job && retry_batch_id_for(active_job)
347
+ retry_batch_id = retry_batch_id_for(active_job)
331
348
  return payload_hash unless retry_batch_id
332
349
 
333
350
  tagged = payload_hash.merge(Batch::METADATA_KEY => retry_batch_id)
@@ -335,14 +352,20 @@ module Pgbus
335
352
  tagged
336
353
  end
337
354
 
355
+ # Only tags: the bulk is counted once per batch below. A retry re-enqueue
356
+ # never takes the bulk path (retry_on enqueues one job at a time).
357
+ def tag_bulk_member(payload_hash, active_job)
358
+ batch_id = member_batch_id(active_job)
359
+ batch_id ? payload_hash.merge(Batch::METADATA_KEY => batch_id) : payload_hash
360
+ end
361
+
338
362
  def enqueue_immediate(queue, jobs, priority: nil)
339
363
  return if jobs.empty?
340
364
 
341
365
  payloads = jobs.map do |j|
342
- inject_batch_metadata(FairShare.inject_metadata(j, Serializer.serialize_job_hash(j)), track: false)
366
+ tag_bulk_member(FairShare.inject_metadata(j, Serializer.serialize_job_hash(j)), j)
343
367
  end
344
- # One guarded increment for the whole bulk, not one per job.
345
- Batch.track_enqueue(payloads) if Thread.current[:pgbus_batch_id]
368
+ track_bulk(payloads)
346
369
  physical = physical_queue(queue, priority)
347
370
  msg_ids = nil
348
371
  msg_ids = Pgbus.client.send_batch(queue, payloads, priority: priority)
@@ -361,6 +384,24 @@ module Pgbus
361
384
  rescue Pgbus::SchemaNotReady => e
362
385
  Pgbus.logger.error { "[Pgbus] #{e.message}" }
363
386
  raise
387
+ ensure
388
+ jobs.each { |j| forget_enqueue_batch_id(j) }
389
+ end
390
+
391
+ # One guarded increment per batch for the whole bulk, not one per job.
392
+ # When a later batch refuses (finished), the batches already counted are
393
+ # uncounted before the error goes up: nothing has been sent yet.
394
+ def track_bulk(payloads)
395
+ tracked = []
396
+ payloads.select { |p| p[Batch::METADATA_KEY] }
397
+ .group_by { |p| p[Batch::METADATA_KEY] }
398
+ .each_value do |members|
399
+ Batch.track_enqueue(members)
400
+ tracked.concat(members)
401
+ end
402
+ rescue StandardError
403
+ tracked.each { |payload| Batch.untrack_enqueue(payload) }
404
+ raise
364
405
  end
365
406
 
366
407
  def scheduled_in_future?(job)
@@ -16,11 +16,33 @@ module Pgbus
16
16
  # Both round-trip through +serialize+/+deserialize+, and both are omitted
17
17
  # from the serialized hash when unset — an unbatched job's payload is
18
18
  # byte-for-byte what it was before this mixin existed.
19
+ #
20
+ # A third, transient id, +enqueue_batch_id+, is the batch this instance
21
+ # was *added* to. +b.add+ / +batch.add+ set it, and +enqueue+ copies the
22
+ # block's tag onto it, before Active Job decides whether to defer the
23
+ # enqueue to after commit (+enqueue_after_transaction_commit+). The
24
+ # deferred enqueue keeps the instance, so the adapter still finds the
25
+ # batch once the block's thread tag is gone (issue #553). It is never
26
+ # serialized and the adapter clears it once the enqueue is done.
19
27
  module BatchId
20
28
  extend ActiveSupport::Concern
21
29
 
22
30
  included do
23
- attr_accessor :batch_id, :callback_batch_id
31
+ attr_accessor :batch_id, :callback_batch_id, :enqueue_batch_id
32
+ end
33
+
34
+ # Mark +jobs+ as added to +batch_id+ (instances without this mixin are
35
+ # left alone; they join through the block's thread tag only).
36
+ def self.capture(jobs, batch_id)
37
+ jobs.each { |job| job.enqueue_batch_id = batch_id if job.respond_to?(:enqueue_batch_id=) }
38
+ end
39
+
40
+ # Plain assignment, not ||=: an id left by an add whose enqueue never
41
+ # ran (rolled back, or aborted by a before_enqueue) must not follow the
42
+ # instance into this one. b.add / batch.add never come through here.
43
+ def enqueue(...)
44
+ self.enqueue_batch_id = Thread.current[:pgbus_batch_id]
45
+ super
24
46
  end
25
47
 
26
48
  def serialize
@@ -59,6 +59,7 @@ module Pgbus
59
59
  raise ArgumentError, UNTAGGED_MESSAGE unless Thread.current[:pgbus_batch_id] == @batch.batch_id
60
60
 
61
61
  jobs = self.class.job_instances(jobs)
62
+ Pgbus::ActiveJob::BatchId.capture(jobs, @batch.batch_id)
62
63
  ::ActiveJob.perform_all_later(jobs) if jobs.any?
63
64
  yield if block_given?
64
65
  jobs
data/lib/pgbus/batch.rb CHANGED
@@ -45,6 +45,19 @@ module Pgbus
45
45
  # Thread.current): b.add raises anywhere else, and a plain perform_later
46
46
  # from a thread or fiber the block spawns does not join the batch.
47
47
  #
48
+ # Inside a database transaction the batch stays pending until the
49
+ # transaction commits: a job with enqueue_after_transaction_commit keeps
50
+ # the batch on its instance (b.add, batch.add and perform_later put it
51
+ # there) and counts itself in at commit, before the batch starts. On a
52
+ # rollback no job is sent and, when pgbus shares the application's
53
+ # database, the batch row is gone with the rest (a separate pgbus
54
+ # database commits the row on its own connection). A bare
55
+ # ActiveJob.perform_all_later in the block does not keep it: use b.add.
56
+ # Add jobs that defer: one sent at once, on a connection other than the
57
+ # caller's, can run before the batch commits and is then accounted for
58
+ # only by the stalled-execution sweep. A job instance is one member (one
59
+ # job_id): enqueue it once.
60
+ #
48
61
  # Pgbus::Batch.start(description: "Import") do |b|
49
62
  # b.on_complete ImportDoneJob
50
63
  # b.add(*rows.map { |row| ImportRowJob.new(row.id) })
@@ -124,7 +137,7 @@ module Pgbus
124
137
  end
125
138
  @started = true
126
139
  count_jobs(&)
127
- start_processing
140
+ self.class.send(:after_transactions_commit) { start_processing }
128
141
  self
129
142
  end
130
143
 
@@ -139,6 +152,7 @@ module Pgbus
139
152
  return jobs if jobs.empty? && block.nil?
140
153
 
141
154
  enqueue do
155
+ Pgbus::ActiveJob::BatchId.capture(jobs, batch_id)
142
156
  ::ActiveJob.perform_all_later(jobs) if jobs.any?
143
157
  yield if block
144
158
  end
@@ -473,6 +487,22 @@ module Pgbus
473
487
  finish_if_needed(try_finish!(batch_id))
474
488
  end
475
489
 
490
+ # Run the end-of-block start (or an open batch's re-check) once every
491
+ # open transaction has committed, and at once when none is open
492
+ # (issue #553). Jobs with enqueue_after_transaction_commit register
493
+ # their enqueue as an after_commit callback during the block; Active
494
+ # Record runs a transaction's callbacks in registration order and
495
+ # appends a savepoint's to its parent's on release, so every one of
496
+ # them has counted itself into the still-pending row before this runs.
497
+ # On a rollback none of them runs, and the row (created in the same
498
+ # transaction) is gone. Active Record 7.1 has no
499
+ # after_all_transactions_commit, and Active Job 7.1 never defers.
500
+ def after_transactions_commit(&)
501
+ return yield unless ::ActiveRecord.respond_to?(:after_all_transactions_commit)
502
+
503
+ ::ActiveRecord.after_all_transactions_commit(&)
504
+ end
505
+
476
506
  def finish_if_needed(result)
477
507
  return result unless result&.fetch(:just_finished, false) && result[:record]
478
508
 
@@ -629,8 +659,10 @@ module Pgbus
629
659
  raise AlreadyFinished, "Can't add jobs into an already finished batch" if finished?
630
660
 
631
661
  count_jobs(&)
632
- reload
633
- self.class.send(:finish_if_needed, BatchEntry.check_finished!(batch_id))
662
+ self.class.send(:after_transactions_commit) do
663
+ reload
664
+ self.class.send(:finish_if_needed, BatchEntry.check_finished!(batch_id))
665
+ end
634
666
  self
635
667
  end
636
668
 
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pgbus/integrations/appsignal/span_subscriber"
4
+
5
+ module Pgbus
6
+ module Integrations
7
+ module Appsignal
8
+ # The `<Handler>#handle` transaction around pgbus.event_processed, and
9
+ # the fallback for an error pgbus.event_failed reports from outside it
10
+ # (an event that could not be built never reaches the span).
11
+ class EventSpan < SpanSubscriber
12
+ private
13
+
14
+ def frame_key = :pgbus_appsignal_event_span
15
+
16
+ def attached_key = :pgbus_appsignal_event_span_error
17
+
18
+ def describe(transaction, payload)
19
+ transaction.set_action_if_nil("#{payload[:handler] || "UnknownHandler"}#handle")
20
+ apply_queue_start(transaction, payload[:published_at])
21
+ transaction.add_tags(handler_tags(payload))
22
+ end
23
+
24
+ # The processed count is taken at finish whether or not the handler
25
+ # raised, as Metrics::Subscriber does: a raised handler counts as
26
+ # processed and, through pgbus.event_failed, as failed.
27
+ def finished(payload, duration)
28
+ tags = { handler: payload[:handler], routing_key: payload[:routing_key] }
29
+ ::Appsignal.add_distribution_value("pgbus_event_duration_ms", duration, tags)
30
+ ::Appsignal.increment_counter("pgbus_event_count", 1, tags.merge(status: "processed"))
31
+ end
32
+
33
+ def handler_tags(payload)
34
+ {
35
+ "handler" => payload[:handler],
36
+ "routing_key" => payload[:routing_key],
37
+ "attempts" => payload[:read_ct]
38
+ }.compact
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pgbus/integrations/appsignal/span_subscriber"
4
+
5
+ module Pgbus
6
+ module Integrations
7
+ module Appsignal
8
+ # The `<JobClass>#perform` transaction around pgbus.executor.execute
9
+ # (deserialize + perform + archive), and the fallback for an error
10
+ # pgbus.job_failed reports from outside it.
11
+ class JobSpan < SpanSubscriber
12
+ private
13
+
14
+ def frame_key = :pgbus_appsignal_job_span
15
+
16
+ def attached_key = :pgbus_appsignal_job_span_error
17
+
18
+ def describe(transaction, payload)
19
+ transaction.set_action_if_nil("#{payload[:job_class] || "UnknownJob"}#perform")
20
+ apply_queue_start(transaction, payload[:enqueued_at])
21
+ transaction.add_tags(job_tags(payload))
22
+ transaction.add_params_if_nil { { arguments: payload[:arguments] } } if payload.key?(:arguments)
23
+ end
24
+
25
+ def finished(payload, duration)
26
+ ::Appsignal.add_distribution_value(
27
+ "pgbus_job_duration_ms",
28
+ duration,
29
+ { queue: payload[:queue], job_class: payload[:job_class] }
30
+ )
31
+ end
32
+
33
+ def job_tags(payload)
34
+ tags = {
35
+ "queue" => payload[:queue],
36
+ "job_class" => payload[:job_class],
37
+ "attempts" => payload[:read_ct]
38
+ }
39
+ tags["active_job_id"] = payload[:job_id] if payload[:job_id]
40
+ tags["provider_job_id"] = payload[:provider_job_id] if payload[:provider_job_id]
41
+ tags["request_id"] = payload[:provider_job_id] || payload[:job_id]
42
+ tags.compact
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Pgbus
6
+ module Integrations
7
+ module Appsignal
8
+ # The AppSignal transaction of one unit of work: a job (JobSpan, on
9
+ # pgbus.executor.execute) or an event handler (EventSpan, on
10
+ # pgbus.event_processed).
11
+ #
12
+ # An ActiveSupport::Notifications listener that responds to #start and
13
+ # #finish is called on both sides of the instrumented block (the Evented
14
+ # subscriber). A finish-only block subscriber runs after the block has
15
+ # returned, so the transaction it opened was never current while the job
16
+ # ran: job code had nothing to attach to, and pgbus.job_failed, fired
17
+ # later, found no transaction to put its error on (issue #552). Here
18
+ # #start opens the transaction before the block runs, so job and handler
19
+ # code, and the Rails error reporter, find it current; #finish records
20
+ # the exception the block raised (the instrumenter puts it in
21
+ # payload[:exception_object]) and completes the transaction.
22
+ #
23
+ # Like AppSignal's own ActiveJob hook, a transaction that is already
24
+ # current is reused and left to its owner to complete.
25
+ #
26
+ # The frame lives in Thread.current[], which is fiber-local, so start and
27
+ # finish pair up per job under execution_mode: :async, the same way
28
+ # AppSignal keeps its current transaction. A span opened inside another
29
+ # of its kind (a handler calling another handler's #process) keeps the
30
+ # enclosing frame and restores it at finish, so the outer span still
31
+ # completes its transaction. An instance holds no state of its own.
32
+ # Nothing an AppSignal call raises reaches the job: it is logged at warn
33
+ # level.
34
+ #
35
+ # Subclasses define #frame_key and #attached_key (the fiber-local slots),
36
+ # #describe(transaction, payload) (action, queue start, tags) and
37
+ # #finished(payload, duration_ms) (the span's metrics).
38
+ class SpanSubscriber
39
+ BACKGROUND_JOB = "background_job"
40
+
41
+ # owned: this span created the transaction, so it completes it.
42
+ # previous: the enclosing span's frame, restored at finish.
43
+ Frame = Struct.new(:started_at, :owned, :previous)
44
+
45
+ def start(_name, _id, payload)
46
+ frame = Thread.current[frame_key] = Frame.new(monotonic_now, false, Thread.current[frame_key])
47
+ guard do
48
+ frame.owned = !::Appsignal::Transaction.current?
49
+ transaction = frame.owned ? ::Appsignal::Transaction.create(BACKGROUND_JOB) : ::Appsignal::Transaction.current
50
+ describe(transaction, payload)
51
+ end
52
+ end
53
+
54
+ def finish(_name, _id, payload)
55
+ frame = Thread.current[frame_key]
56
+ guard do
57
+ Thread.current[attached_key] = attach(payload[:exception_object])
58
+ finished(payload, monotonic_now - frame.started_at)
59
+ end
60
+ ensure
61
+ guard do
62
+ Thread.current[frame_key] = frame.previous
63
+ ::Appsignal::Transaction.complete_current! if frame.owned
64
+ end
65
+ end
66
+
67
+ # pgbus.job_failed and pgbus.event_failed fire after #finish. An error
68
+ # the span recorded is already on its transaction. One raised before
69
+ # the span opened (a message body that is not JSON, an event that
70
+ # cannot be built, a dead-letter move that failed) had no transaction,
71
+ # so it is reported on one of its own, named and tagged like the span's
72
+ # (or added to a transaction that is current, which is left as it is).
73
+ def report_unattached(error, payload)
74
+ attached = Thread.current[attached_key]
75
+ Thread.current[attached_key] = nil
76
+ return if error.nil? || error.equal?(attached)
77
+ return ::Appsignal::Transaction.current.set_error(error) if ::Appsignal::Transaction.current?
78
+
79
+ ::Appsignal.report_error(error) do |transaction|
80
+ transaction.set_namespace(BACKGROUND_JOB)
81
+ describe(transaction, payload)
82
+ end
83
+ end
84
+
85
+ private
86
+
87
+ # Returns the exception it recorded, nil when there was none.
88
+ def attach(error)
89
+ return unless error && ::Appsignal::Transaction.current?
90
+
91
+ ::Appsignal::Transaction.current.set_error(error)
92
+ error
93
+ end
94
+
95
+ # AppSignal expects queue-start as Unix epoch milliseconds. Pgbus
96
+ # carries it as either an ISO-8601 String or a Time — both happen
97
+ # in practice (executor passes the JSON string, handler passes a
98
+ # parsed Time).
99
+ def apply_queue_start(transaction, value)
100
+ return unless value
101
+
102
+ millis =
103
+ case value
104
+ when Time
105
+ (value.to_f * 1_000).to_i
106
+ when String
107
+ (Time.parse(value).to_f * 1_000).to_i
108
+ when Numeric
109
+ value.to_i
110
+ end
111
+ transaction.set_queue_start(millis) if millis
112
+ rescue ArgumentError
113
+ # Unparseable timestamp — skip rather than blow up.
114
+ end
115
+
116
+ def guard
117
+ yield
118
+ rescue StandardError => e
119
+ Pgbus.logger.warn { "[Pgbus::AppSignal] subscriber error: #{e.class}: #{e.message}" }
120
+ end
121
+
122
+ def monotonic_now
123
+ ::Process.clock_gettime(::Process::CLOCK_MONOTONIC, :float_millisecond)
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "time"
3
+ require "pgbus/integrations/appsignal/job_span"
4
+ require "pgbus/integrations/appsignal/event_span"
4
5
 
5
6
  module Pgbus
6
7
  module Integrations
@@ -10,16 +11,23 @@ module Pgbus
10
11
  #
11
12
  # Job and event-handler events open a BACKGROUND_JOB transaction so they
12
13
  # appear under AppSignal's "Performance > Background jobs" view, with
13
- # action `<JobClass>#perform` or `<HandlerClass>#handle`. All other
14
- # events are reported as counters or distributions only.
14
+ # action `<JobClass>#perform` or `<HandlerClass>#handle`. The transaction
15
+ # is opened when pgbus.executor.execute / pgbus.event_processed starts and
16
+ # completed when it finishes (JobSpan, EventSpan), so it is current while the
17
+ # job or handler runs and carries the error the execution raised. All
18
+ # other events are reported as counters or distributions only.
15
19
  #
16
20
  # All metric names are prefixed `pgbus_`. Tag keys avoid high-cardinality
17
21
  # values (no msg_id, no event_id) so AppSignal's metric storage stays
18
22
  # efficient.
19
23
  module Subscriber
20
- BACKGROUND_JOB = "background_job"
21
24
  METRIC_PREFIX = "pgbus_"
22
- private_constant :BACKGROUND_JOB, :METRIC_PREFIX
25
+
26
+ # Built once, at load: a span keeps its state in a fiber-local frame,
27
+ # never in itself.
28
+ JOB_SPAN = JobSpan.new.freeze
29
+ EVENT_SPAN = EventSpan.new.freeze
30
+ private_constant :METRIC_PREFIX, :JOB_SPAN, :EVENT_SPAN
23
31
 
24
32
  # Tracked so we can detach in reset! (used by specs). Installed once
25
33
  # at boot (the engine's after_initialize); @install_lock makes the
@@ -54,11 +62,11 @@ module Pgbus
54
62
 
55
63
  def subscribe_all
56
64
  [
57
- subscribe("pgbus.executor.execute") { |event| on_executor_execute(event) },
65
+ ActiveSupport::Notifications.subscribe("pgbus.executor.execute", JOB_SPAN),
58
66
  subscribe("pgbus.job_completed") { |event| on_job_completed(event) },
59
67
  subscribe("pgbus.job_failed") { |event| on_job_failed(event) },
60
68
  subscribe("pgbus.job_dead_lettered") { |event| on_job_dead_lettered(event) },
61
- subscribe("pgbus.event_processed") { |event| on_event_processed(event) },
69
+ ActiveSupport::Notifications.subscribe("pgbus.event_processed", EVENT_SPAN),
62
70
  subscribe("pgbus.event_failed") { |event| on_event_failed(event) },
63
71
  subscribe("pgbus.event_dead_lettered") { |event| on_event_dead_lettered(event) },
64
72
  subscribe("pgbus.client.send_message") { |event| on_send_message(event) },
@@ -98,22 +106,6 @@ module Pgbus
98
106
 
99
107
  # ── Job execution ───────────────────────────────────────────────
100
108
 
101
- def on_executor_execute(event)
102
- payload = event.payload
103
- transaction = ::Appsignal::Transaction.create(BACKGROUND_JOB)
104
- transaction.set_action_if_nil("#{payload[:job_class] || "UnknownJob"}#perform")
105
- apply_queue_start(transaction, payload[:enqueued_at])
106
- transaction.add_tags(job_tags(payload))
107
- transaction.add_params_if_nil { { arguments: payload[:arguments] } }
108
- ::Appsignal.add_distribution_value(
109
- "#{METRIC_PREFIX}job_duration_ms",
110
- event.duration,
111
- { queue: payload[:queue], job_class: payload[:job_class] }
112
- )
113
- ensure
114
- ::Appsignal::Transaction.complete_current!
115
- end
116
-
117
109
  def on_job_completed(event)
118
110
  payload = event.payload
119
111
  ::Appsignal.increment_counter(
@@ -123,15 +115,18 @@ module Pgbus
123
115
  )
124
116
  end
125
117
 
118
+ # The counter has its own guard so a raise there cannot keep the
119
+ # error from being reported.
126
120
  def on_job_failed(event)
127
121
  payload = event.payload
128
- ::Appsignal.increment_counter(
129
- "#{METRIC_PREFIX}queue_job_count",
130
- 1,
131
- { queue: payload[:queue], job_class: payload[:job_class], status: "failed" }
132
- )
133
- err = payload[:exception_object]
134
- ::Appsignal.set_error(err) if err && ::Appsignal.respond_to?(:set_error)
122
+ safely do
123
+ ::Appsignal.increment_counter(
124
+ "#{METRIC_PREFIX}queue_job_count",
125
+ 1,
126
+ { queue: payload[:queue], job_class: payload[:job_class], status: "failed" }
127
+ )
128
+ end
129
+ JOB_SPAN.report_unattached(payload[:exception_object], payload)
135
130
  end
136
131
 
137
132
  def on_job_dead_lettered(event)
@@ -145,40 +140,23 @@ module Pgbus
145
140
 
146
141
  # ── Event handler ───────────────────────────────────────────────
147
142
 
148
- def on_event_processed(event)
149
- payload = event.payload
150
- transaction = ::Appsignal::Transaction.create(BACKGROUND_JOB)
151
- transaction.set_action_if_nil("#{payload[:handler] || "UnknownHandler"}#handle")
152
- apply_queue_start(transaction, payload[:published_at])
153
- transaction.add_tags(handler_tags(payload))
154
- ::Appsignal.add_distribution_value(
155
- "#{METRIC_PREFIX}event_duration_ms",
156
- event.duration,
157
- { handler: payload[:handler], routing_key: payload[:routing_key] }
158
- )
159
- ::Appsignal.increment_counter(
160
- "#{METRIC_PREFIX}event_count",
161
- 1,
162
- { handler: payload[:handler], routing_key: payload[:routing_key], status: "processed" }
163
- )
164
- ensure
165
- ::Appsignal::Transaction.complete_current!
166
- end
167
-
143
+ # The counter has its own guard so a raise there cannot keep the
144
+ # error from being reported.
168
145
  def on_event_failed(event)
169
146
  payload = event.payload
170
- ::Appsignal.increment_counter(
171
- "#{METRIC_PREFIX}event_count",
172
- 1,
173
- { handler: payload[:handler], routing_key: payload[:routing_key], status: "failed" }
174
- )
175
- err = payload[:exception_object]
176
- ::Appsignal.set_error(err) if err && ::Appsignal.respond_to?(:set_error)
147
+ safely do
148
+ ::Appsignal.increment_counter(
149
+ "#{METRIC_PREFIX}event_count",
150
+ 1,
151
+ { handler: payload[:handler], routing_key: payload[:routing_key], status: "failed" }
152
+ )
153
+ end
154
+ EVENT_SPAN.report_unattached(payload[:exception_object], payload)
177
155
  end
178
156
 
179
157
  # A counter only, like on_job_dead_lettered: nothing is raised at
180
- # move time, and the handler's error already went out through
181
- # pgbus.event_failed on the attempt that raised (issue #543). A body
158
+ # move time, and the handler's error already went out on the
159
+ # attempt that raised (issues #543, #552). A body
182
160
  # that is not JSON leaves handler and routing_key nil; compact drops
183
161
  # those tags, as Metrics::Subscriber does.
184
162
  def on_event_dead_lettered(event)
@@ -295,49 +273,6 @@ module Pgbus
295
273
  { reason: payload[:reason], kind: "consumer" }
296
274
  )
297
275
  end
298
-
299
- # ── Helpers ─────────────────────────────────────────────────────
300
-
301
- # AppSignal expects queue-start as Unix epoch milliseconds. Pgbus
302
- # carries it as either an ISO-8601 String or a Time — both happen
303
- # in practice (executor passes the JSON string, handler passes a
304
- # parsed Time).
305
- def apply_queue_start(transaction, value)
306
- return unless value
307
-
308
- millis =
309
- case value
310
- when Time
311
- (value.to_f * 1_000).to_i
312
- when String
313
- (Time.parse(value).to_f * 1_000).to_i
314
- when Numeric
315
- value.to_i
316
- end
317
- transaction.set_queue_start(millis) if millis
318
- rescue ArgumentError
319
- # Unparseable timestamp — skip rather than blow up.
320
- end
321
-
322
- def job_tags(payload)
323
- tags = {
324
- "queue" => payload[:queue],
325
- "job_class" => payload[:job_class],
326
- "attempts" => payload[:read_ct]
327
- }
328
- tags["active_job_id"] = payload[:job_id] if payload[:job_id]
329
- tags["provider_job_id"] = payload[:provider_job_id] if payload[:provider_job_id]
330
- tags["request_id"] = payload[:provider_job_id] || payload[:job_id]
331
- tags.compact
332
- end
333
-
334
- def handler_tags(payload)
335
- {
336
- "handler" => payload[:handler],
337
- "routing_key" => payload[:routing_key],
338
- "attempts" => payload[:read_ct]
339
- }.compact
340
- end
341
276
  end
342
277
  end
343
278
  end
data/lib/pgbus/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pgbus
4
- VERSION = "0.19.3"
4
+ VERSION = "0.19.4"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pgbus
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.19.3
4
+ version: 0.19.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson
@@ -364,7 +364,10 @@ files:
364
364
  - lib/pgbus/integrations/appsignal/dashboards/pgbus_health.json
365
365
  - lib/pgbus/integrations/appsignal/dashboards/pgbus_streams.json
366
366
  - lib/pgbus/integrations/appsignal/dashboards/pgbus_throughput.json
367
+ - lib/pgbus/integrations/appsignal/event_span.rb
368
+ - lib/pgbus/integrations/appsignal/job_span.rb
367
369
  - lib/pgbus/integrations/appsignal/probe.rb
370
+ - lib/pgbus/integrations/appsignal/span_subscriber.rb
368
371
  - lib/pgbus/integrations/appsignal/subscriber.rb
369
372
  - lib/pgbus/log_formatter.rb
370
373
  - lib/pgbus/mcp.rb