pgbus 0.19.1 → 0.19.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fc9755d4591c2de1b1a8ba1d5f6e4c20073bdac018049f31572338e354a8f6bb
4
- data.tar.gz: ee79c33310552873c05ceb8c2c133079aafb1c07a8b8fb1074ac79d76cafe68f
3
+ metadata.gz: cb2ec8b622a127040f7ec0d98d514397b66fe2bda51d910bdf7109aee1cb9201
4
+ data.tar.gz: be53e0ecc3dc3700cd8daa769cf067190244dc4b957d1c8e022adb2515f695df
5
5
  SHA512:
6
- metadata.gz: a5b4f622fb2503d67dd38cdf7b6175fd8437a82c07d536d4274a8a57fec877125e08e97583763817a33b3727f148262fdc3c0eeb9ee91d3230ce7a04057ee3a6
7
- data.tar.gz: 819e82ba9a43259e8c1be15f835e6c23922fd530d7cc793dcda401a9f93bc1c592eb0f910449b244218bf6c0a0c20275090381fe0f1f8b052b4e6bfbc1321f38
6
+ metadata.gz: 65ad9a9f4bf9a3bdb9263a0603178370595b2b552336c4a74dacfb5d5575cf14f75ec1e59c2dbcec3c148bd0d5e6a8782fbf47bca89e5d0eb68ba5504b74743c
7
+ data.tar.gz: a3389974026f5626913e1038c39cfb099f8f982dcd5df968ad7ba09e0b6da747a3e6a41b4823ea843415558d3bd576db7922836e3556c43c94ae56c48f2ff529
data/CHANGELOG.md CHANGED
@@ -3,6 +3,12 @@
3
3
  ### Added
4
4
 
5
5
  - **Deploy tasks: one-time work owed by a release, run once the fleet has rolled over.** Declare a backfill, data fix or cache warm in `config/deploy_tasks.yml` (the shape of `recurring.yml` minus `schedule:`, plus `version:` and `every_deploy:`) or `config.deploy_tasks`; the scheduler enqueues it once per `(key, version)` (or once per revision for an `every_deploy:` task) after every live pgbus process reports the new app revision and a worker on it is alive, so it never runs on old code during the overlap window and two schedulers never both fire it. It is not exactly-once: a failure between the enqueue and recording the run can repeat a task after a scheduler restart, and a scheduler killed between claiming and enqueueing loses that arm, so deploy tasks must be idempotent. Every pgbus process stamps the revision (`config.app_revision`, `PGBUS_REVISION`, `DASH_VERSION`, `KAMAL_VERSION` or the `REVISION` file) into its heartbeat. The dashboard gets a "Deploy tasks" page with the rollover status, Run now and Enable/Disable; run history lives in `pgbus_deploy_task_runs` (`deploy_task_run_retention`, default 90 days); `pgbus.deploy_task.enqueue` is instrumented and the MCP server gains `pgbus_deploy_tasks`. Set up with `rails generate pgbus:add_deploy_tasks` (`pgbus:update` detects the missing tables). Refs #536.
6
+ - **`pgbus.event_dead_lettered`: the event consumer says when it dead-letters an event.** A job that exhausted `max_retries` emitted `pgbus.job_dead_lettered`; an event moved to `<queue>_dlq` the same way emitted nothing, so a pager, an error tracker or a metrics counter had no push signal for dead events. `Process::Consumer` now emits `pgbus.event_dead_lettered` once per dead-lettered event, after `move_to_dead_letter` returns and the failed-event row is cleared, with `queue_name` (logical), `routing_key`, `handler` (the class that owns the queue in this process, or nil), `event_id`, `read_ct`, `msg_id` and `error` (the class of the last recorded failure, or nil). `handler` is nil unless exactly one subscriber owns the queue. A body that is not a JSON object (unparseable text, or a jsonb scalar or array written straight to the queue) is still dead-lettered, with `routing_key`, `handler` and `event_id` nil, and its `error` is the consumer's own failure to read it (`JSON::ParserError`, `NoMethodError` or `TypeError`): no handler ran. A move that raises emits nothing. A subscriber that raises is logged at debug level and does not change the outcome: the message stays dead-lettered, with one `dead_lettered` stat, no `failed` stat and no circuit-breaker failure. The AppSignal and metrics subscribers count it as `pgbus_event_count` with `status: "dead_lettered"`, tagged `handler` and `routing_key`. `pgbus.job_dead_lettered` and the consumer's success path are unchanged: the envelope is parsed a second time only on the dead-letter branch. Refs #543.
7
+ - **feat(batches): `Pgbus.batch do |b| … end`, a builder for starting a batch.** `b.on_complete`, `b.on_success` and `b.on_failure` take an ActiveJob class or a configured instance; `b.add(*jobs)` takes job instances (each keeps its own `.set(wait:, queue:, priority:)`) and sends them in one bulk enqueue. The batch row exists, and a `uniqueness_key` is held, before the block runs, and the batch stays pending until the block returns, so the calls can come in any order and a job that finishes while the block is still adding cannot finish the batch early. Every `perform_later` inside the block joins the batch, as inside `Batch#enqueue`. With `on_conflict: :discard` or `:log` and the key held, the block does not run. Inside a job, `batch(description: …) { |b| … }` opens a new, independent batch; `batch` without a block still reads the job's own batch. A member job adds siblings with `batch.add(NextJob.new(id))`, the open-batch form of `batch.enqueue { … }`. `Pgbus::Batch.start` is the same as `Pgbus.batch`, and `Pgbus::Batch.new` + `#enqueue` stay as the lower-level API. Refs #538.
8
+
9
+ ### Changed
10
+
11
+ - **The batch completion callback is `on_complete`.** `Pgbus::Batch.new(on_complete:)`, the builder's `b.on_complete` and the `#on_complete` reader replace `on_finish`, which keeps working as a deprecated alias until 1.0 and logs one warning per process. The database columns stay `on_finish_class` / `on_finish_job` until the 1.0 schema cleanup, so batches created before upgrading still fire, and the `pgbus.batch_finished` instrumentation event keeps its name. The dashboard's batch page labels the callback "On Complete". Refs #538.
6
12
 
7
13
  ## [0.19.0] - 2026-10-16
8
14
 
data/README.md CHANGED
@@ -801,28 +801,27 @@ How Pgbus integrates with your application's transactions and tracks groups of r
801
801
  Coordinate groups of jobs with callbacks when all complete:
802
802
 
803
803
  ```ruby
804
- batch = Pgbus::Batch.new(
805
- on_finish: BatchFinishedJob,
806
- on_success: BatchSucceededJob,
807
- on_discard: BatchFailedJob,
808
- description: "Import users",
809
- properties: { initiated_by: current_user.id }
810
- )
811
-
812
- batch.enqueue do
813
- users.each { |user| ImportUserJob.perform_later(user.id) }
804
+ batch = Pgbus.batch(description: "Import users", properties: { initiated_by: current_user.id }) do |b|
805
+ b.on_complete BatchFinishedJob
806
+ b.on_success BatchSucceededJob
807
+ b.on_failure BatchFailedJob
808
+ b.add(*users.map { |user| ImportUserJob.new(user.id) })
814
809
  end
815
810
  ```
816
811
 
812
+ `b.add` takes job instances (each keeps its own `.set(wait:, queue:)`) and sends them in one bulk enqueue. The batch row exists before the block runs and stays pending until it returns, so a job that finishes while the block is still adding cannot finish the batch early. Set callbacks first: if the block raises after an `add`, the added jobs still run and the batch finishes later with only the callbacks set by then. A block that goes `config.batch_stall_threshold` (default 5 minutes) without adding a job is started by the stalled-batch sweep, so keep slow work outside it. Callbacks are job classes or configured instances, never blocks: they are stored on the batch row.
813
+
814
+ Inside a job, `batch { |b| ... }` opens a new batch the same way, and a job that is already a member adds siblings with `batch.add(NextJob.new(id))`. `Pgbus::Batch.new(on_complete: ...)` plus `batch.enqueue { ... }` remains the lower-level form.
815
+
817
816
  #### Callbacks
818
817
 
819
818
  | Callback | Fired when |
820
819
  |----------|------------|
821
- | `on_finish` | All jobs completed (success or discard) |
822
- | `on_success` | All jobs completed successfully (zero discarded) |
823
- | `on_discard` | At least one job was dead-lettered |
820
+ | `on_complete` | All jobs completed (success or failure). `on_finish` is a deprecated alias until 1.0 |
821
+ | `on_success` | All jobs completed successfully (zero failed) |
822
+ | `on_failure` | At least one job was dead-lettered. `on_discard` is a deprecated alias until 1.0 |
824
823
 
825
- Callback jobs receive the batch `properties` hash as their argument:
824
+ Callback jobs given as a bare class receive the batch `properties` hash as their argument:
826
825
 
827
826
  ```ruby
828
827
  class BatchFinishedJob < ApplicationJob
@@ -838,23 +837,18 @@ end
838
837
  `ensures_uniqueness` protects one job. A multi-job run — a nightly import that fans out into hundreds of chunk jobs, a pipeline whose jobs enqueue the next stage — has no single job that lives for the whole run, so a lock on the entry job releases the moment that job finishes and the next scheduler tick starts a second run on top of the first. Give the batch the lock instead:
839
838
 
840
839
  ```ruby
841
- batch = Pgbus::Batch.new(
842
- uniqueness_key: "perfecta-daily",
843
- on_conflict: :discard, # :reject (raise AlreadyRunning), :discard, :log
844
- on_success: ExportPricesJob
845
- )
846
-
847
- batch.enqueue do
848
- ImportChunkJob.perform_later("products", 0)
840
+ Pgbus.batch(uniqueness_key: "perfecta-daily", on_conflict: :discard) do |b| # :reject (raise AlreadyRunning), :discard, :log
841
+ b.on_success ExportPricesJob
842
+ b.add(ImportChunkJob.new("products", 0))
849
843
  end
850
844
  ```
851
845
 
852
- The lock is taken at `enqueue` and held until the batch finishes — through every open-batch `batch.enqueue` add from inside a running job — then released in the same single-winner finish that fires the callbacks, so completion, the stalled-batch sweep and cleanup all release it. While it is held, another `Pgbus::Batch.new(uniqueness_key: "perfecta-daily").enqueue { ... }` raises `Pgbus::Batch::AlreadyRunning` (`:reject`), or skips its block and returns a batch that answers `discarded?` (`:discard`, `:log`). The row lives in `pgbus_uniqueness_keys` as `batch:<key>`, so it never collides with a job's key and shows up in the dashboard like any other lock; the reaper judges it by the batch's status, never by queue contents.
846
+ The lock is taken at the top of the block (or at `enqueue`) and held until the batch finishes — through every open-batch `batch.add` from inside a running job — then released in the same single-winner finish that fires the callbacks, so completion, the stalled-batch sweep and cleanup all release it. While it is held, another `Pgbus.batch(uniqueness_key: "perfecta-daily") { ... }` raises `Pgbus::Batch::AlreadyRunning` (`:reject`), or skips its block and returns a batch that answers `discarded?` (`:discard`, `:log`). The row lives in `pgbus_uniqueness_keys` as `batch:<key>`, so it never collides with a job's key and shows up in the dashboard like any other lock; the reaper judges it by the batch's status, never by queue contents.
853
847
 
854
848
  #### How batches work
855
849
 
856
- 1. `Batch.new(...)` creates a tracking row in `pgbus_batches` with `status: "pending"`
857
- 2. `batch.enqueue { ... }` tags each enqueued job with the `pgbus_batch_id` in its payload
850
+ 1. The top of the `Pgbus.batch` block (or the first `batch.enqueue`) creates a tracking row in `pgbus_batches` with `status: "pending"`
851
+ 2. Each job enqueued in the block is tagged with the `pgbus_batch_id` in its payload
858
852
  3. After each job completes or is dead-lettered, the executor atomically updates the batch counters
859
853
  4. When `completed_jobs + discarded_jobs == total_jobs`, the batch status flips to `"finished"` and callback jobs are enqueued
860
854
  5. The dispatcher cleans up finished batches older than 7 days
@@ -1017,7 +1011,7 @@ c.metrics_backend = MyOpenTelemetryBackend.new
1017
1011
  | `pgbus_queue_job_count` | counter | `queue`, `job_class`, `status` (`processed`/`failed`/`dead_lettered`) |
1018
1012
  | `pgbus_visibility_extended` | counter | `queue`, `job_class` — one per re-armed visibility timeout; a class listed here outlives `visibility_timeout` |
1019
1013
  | `pgbus_job_duration_ms` | histogram | `queue`, `job_class` |
1020
- | `pgbus_event_count` | counter | `handler`, `routing_key`, `status` |
1014
+ | `pgbus_event_count` | counter | `handler`, `routing_key`, `status` (`processed`/`failed`/`skipped`/`dead_lettered`), plus `reason` on `skipped` only (`completed`/`cached`/`owned`) |
1021
1015
  | `pgbus_event_duration_ms` | histogram | `handler`, `routing_key` |
1022
1016
  | `pgbus_messages_sent` / `pgbus_messages_read` | counter | `queue` |
1023
1017
  | `pgbus_stream_broadcast_count` | counter | `stream`, `deferred` |
@@ -1052,7 +1046,7 @@ ActiveSupport::Notifications.subscribe(/^pgbus\./) do |name, start, finish, _id,
1052
1046
  end
1053
1047
  ```
1054
1048
 
1055
- Metrics-relevant events emitted (the subset the metrics subscriber maps; see `lib/pgbus/instrumentation.rb` for the full `pgbus.*` catalog): `pgbus.executor.execute`, `pgbus.job_completed`, `pgbus.job_failed`, `pgbus.job_dead_lettered`, `pgbus.event_processed`, `pgbus.event_failed`, `pgbus.client.send_message`, `pgbus.client.send_batch`, `pgbus.client.read_batch`, `pgbus.client.pool`, `pgbus.stream.broadcast`, `pgbus.outbox.publish`, `pgbus.recurring.enqueue`, `pgbus.worker.recycle`, `pgbus.consumer.recycle`. Payload keys are documented in `lib/pgbus/instrumentation.rb`.
1049
+ Metrics-relevant events emitted (the subset the metrics subscriber maps; see `lib/pgbus/instrumentation.rb` for the full `pgbus.*` catalog): `pgbus.executor.execute`, `pgbus.job_completed`, `pgbus.job_failed`, `pgbus.job_dead_lettered`, `pgbus.event_processed`, `pgbus.event_failed`, `pgbus.event_dead_lettered`, `pgbus.event_skipped`, `pgbus.job_visibility_extended`, `pgbus.client.send_message`, `pgbus.client.send_batch`, `pgbus.client.read_batch`, `pgbus.client.pool`, `pgbus.stream.broadcast`, `pgbus.outbox.publish`, `pgbus.recurring.enqueue`, `pgbus.worker.recycle`, `pgbus.consumer.recycle`. Payload keys are documented in `lib/pgbus/instrumentation.rb`.
1056
1050
 
1057
1051
  ### Connection pool metrics
1058
1052
 
@@ -23,7 +23,7 @@ en:
23
23
  finished_at: Finished At
24
24
  not_found: Batch not found
25
25
  on_failure: On Failure
26
- on_finish: On Finish
26
+ on_finish: On Complete
27
27
  on_success: On Success
28
28
  progress: Progress
29
29
  properties: Properties
@@ -33,7 +33,10 @@ module Pgbus
33
33
  say " 2. Restart pgbus: bin/pgbus start"
34
34
  say ""
35
35
  say "You can now pass a configured ActiveJob instance to a batch:"
36
- say " Pgbus::Batch.new(on_finish: ReportJob.new.set(queue: :critical, wait: 5.minutes))"
36
+ say " Pgbus.batch do |b|"
37
+ say " b.on_complete ReportJob.new.set(queue: :critical, wait: 5.minutes)"
38
+ say " b.add(*rows.map { |row| ImportRowJob.new(row.id) })"
39
+ say " end"
37
40
  say ""
38
41
  end
39
42
 
@@ -8,9 +8,9 @@ module Pgbus
8
8
  #
9
9
  # * +batch_id+ — the batch this job is a *member* of. Assigned by the
10
10
  # executor from the payload's +pgbus_batch_id+ before +perform+, so a
11
- # running job can call +batch.enqueue+ to add siblings.
11
+ # running job can call +batch.add+ to add siblings.
12
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
13
+ # +on_complete+/+on_success+/+on_failure+ jobs at fire time. A callback is
14
14
  # never a member of the batch it reports on, so its +batch_id+ is nil.
15
15
  #
16
16
  # Both round-trip through +serialize+/+deserialize+, and both are omitted
@@ -36,8 +36,14 @@ module Pgbus
36
36
  self.callback_batch_id = job_data["callback_batch_id"]
37
37
  end
38
38
 
39
- # The batch this job reports on, or is a member of. nil when neither.
40
- def batch
39
+ # Without a block: the batch this job reports on, or is a member of; nil
40
+ # when neither. With a block: open a new, independent batch through
41
+ # Pgbus.batch (this job's own membership is untouched).
42
+ #
43
+ # batch(description: "Import") { |b| b.add(*rows.map { |r| RowJob.new(r.id) }) }
44
+ def batch(**options, &block)
45
+ return Pgbus.batch(**options, &block) if block
46
+ raise ArgumentError, "batch(...) with options needs a block: batch(description: ...) { |b| ... }" if options.any?
41
47
  return @batch if defined?(@batch)
42
48
 
43
49
  id = callback_batch_id || batch_id
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ class Batch
5
+ # What Pgbus.batch (and +batch { |b| ... }+ inside a job) yields: callback
6
+ # setters and +add+ on a batch that is open for the length of the block.
7
+ #
8
+ # Pgbus.batch(description: "Import #{source.id}") do |b|
9
+ # b.on_complete ImportDoneJob.new.set(queue: :critical)
10
+ # b.on_failure ImportFailedJob
11
+ # b.add(*source.rows.map { |row| ImportRowJob.new(row.id) })
12
+ # end
13
+ #
14
+ # The batch row exists (and a uniqueness key is held) before the block
15
+ # runs, and it stays pending until the block returns, so the order of the
16
+ # calls does not matter and a job that completes during the block cannot
17
+ # finish the batch early. Once the block has returned the builder is
18
+ # closed: add more jobs through +batch.add+ on the returned batch.
19
+ class Builder
20
+ CLOSED_MESSAGE = "the batch builder only works inside its Pgbus.batch block; " \
21
+ "add more jobs later with batch.add on the batch Pgbus.batch returned"
22
+ UNTAGGED_MESSAGE = "b.add ran on a thread or fiber outside its Pgbus.batch block (or inside another " \
23
+ "batch's block), where its jobs would not join the batch; call b.add from the block itself"
24
+
25
+ # Flatten +jobs+ and check that each one is an ActiveJob instance.
26
+ def self.job_instances(jobs)
27
+ jobs = jobs.flatten
28
+ invalid = jobs.grep_v(::ActiveJob::Base)
29
+ return jobs if invalid.empty?
30
+
31
+ raise ArgumentError,
32
+ "a batch takes job instances (MyJob.new(args), optionally .set(wait:, queue:)), got #{invalid.first.inspect}"
33
+ end
34
+
35
+ def initialize(batch)
36
+ @batch = batch
37
+ @closed = false
38
+ end
39
+
40
+ def on_complete(job = nil, &) = set_callback(:on_complete, job, &)
41
+
42
+ def on_success(job = nil, &) = set_callback(:on_success, job, &)
43
+
44
+ def on_failure(job = nil, &) = set_callback(:on_failure, job, &)
45
+
46
+ # Deprecated alias of #on_complete until 1.0.
47
+ def on_finish(job = nil, &)
48
+ Batch.warn_on_finish_deprecated
49
+ set_callback(:on_complete, job, &)
50
+ end
51
+
52
+ # Enqueue job instances into the batch with one bulk send (and anything
53
+ # the block enqueues). Returns the jobs. Membership is the batch tag the
54
+ # block holds (Thread.current, so fiber-local), so an add from another
55
+ # thread or fiber, or from inside a nested batch's block, raises rather
56
+ # than send jobs that would not be counted.
57
+ def add(*jobs)
58
+ raise ArgumentError, CLOSED_MESSAGE if @closed
59
+ raise ArgumentError, UNTAGGED_MESSAGE unless Thread.current[:pgbus_batch_id] == @batch.batch_id
60
+
61
+ jobs = self.class.job_instances(jobs)
62
+ ::ActiveJob.perform_all_later(jobs) if jobs.any?
63
+ yield if block_given?
64
+ jobs
65
+ end
66
+
67
+ # Called by Batch.start when the block returns or raises.
68
+ def close
69
+ @closed = true
70
+ end
71
+
72
+ private
73
+
74
+ def set_callback(slot, job, &block)
75
+ raise ArgumentError, CLOSED_MESSAGE if @closed
76
+
77
+ unless block.nil? && callback_job?(job)
78
+ raise ArgumentError,
79
+ "#{slot} takes an ActiveJob class or a configured instance (MyJob.new.set(queue: :critical)), " \
80
+ "not #{block ? "a block" : job.inspect}: callbacks are stored on the batch row"
81
+ end
82
+
83
+ @batch.send(:write_callback, slot, job)
84
+ self
85
+ end
86
+
87
+ def callback_job?(job)
88
+ (job.is_a?(Class) && job < ::ActiveJob::Base) || job.is_a?(::ActiveJob::Base)
89
+ end
90
+ end
91
+ end
92
+ end
data/lib/pgbus/batch.rb CHANGED
@@ -21,21 +21,57 @@ module Pgbus
21
21
  LOCK_QUEUE_PREFIX = "batch:"
22
22
  VALID_CONFLICTS = %i[reject discard log].freeze
23
23
 
24
+ # The callback slots, each mapped to the prefix of its pgbus_batches
25
+ # columns. on_complete keeps the on_finish_* columns until the 1.0 schema
26
+ # cleanup, so rows written before the rename still fire.
27
+ CALLBACK_COLUMNS = { on_complete: "on_finish", on_success: "on_success", on_failure: "on_failure" }.freeze
28
+
24
29
  attr_reader :batch_id, :properties, :description,
25
- :on_finish, :on_success, :on_failure,
30
+ :on_complete, :on_success, :on_failure,
26
31
  :uniqueness_key, :on_conflict
27
32
 
33
+ # Deprecated alias of #on_complete until 1.0.
34
+ def on_finish = on_complete
35
+
28
36
  def on_discard
29
37
  on_failure
30
38
  end
31
39
 
40
+ # Open a batch, yield a Pgbus::Batch::Builder, and start the batch when
41
+ # the block returns. Every job the block enqueues (through +b.add+ or a
42
+ # plain +perform_later+) joins the batch, which stays pending, and so
43
+ # cannot finish, until the block is done. Keywords go to Batch.new.
44
+ # Membership is the block's own thread or fiber (the tag lives in
45
+ # Thread.current): b.add raises anywhere else, and a plain perform_later
46
+ # from a thread or fiber the block spawns does not join the batch.
47
+ #
48
+ # Pgbus::Batch.start(description: "Import") do |b|
49
+ # b.on_complete ImportDoneJob
50
+ # b.add(*rows.map { |row| ImportRowJob.new(row.id) })
51
+ # end
52
+ def self.start(**, &block)
53
+ raise ArgumentError, "Pgbus.batch needs a block: Pgbus.batch { |b| b.add(...) }" unless block
54
+
55
+ batch = new(**)
56
+ builder = Builder.new(batch)
57
+ begin
58
+ batch.enqueue { yield builder }
59
+ ensure
60
+ builder.close
61
+ end
62
+ batch
63
+ end
64
+
65
+ # @param on_complete [Class, ActiveJob::Base, nil] fired when every job
66
+ # reached a terminal state (+on_finish:+ is a deprecated alias)
32
67
  # @param uniqueness_key [String, nil] at most one unfinished batch with
33
68
  # this key may exist; see #enqueue for what happens to the next one
34
69
  # @param on_conflict [Symbol] :reject (raise AlreadyRunning), :discard
35
70
  # (skip the block silently) or :log (skip the block, warn)
36
- def initialize(on_finish: nil, on_success: nil, on_discard: nil, on_failure: nil, description: nil, properties: {},
37
- uniqueness_key: nil, on_conflict: :reject)
71
+ def initialize(on_complete: nil, on_finish: nil, on_success: nil, on_discard: nil, on_failure: nil,
72
+ description: nil, properties: {}, uniqueness_key: nil, on_conflict: :reject)
38
73
  raise ArgumentError, "pass on_failure: only — on_discard: is a deprecated alias" if on_discard && on_failure
74
+ raise ArgumentError, "pass on_complete: only — on_finish: is a deprecated alias" if on_finish && on_complete
39
75
  unless VALID_CONFLICTS.include?(on_conflict)
40
76
  raise ArgumentError, "on_conflict must be one of #{VALID_CONFLICTS.join(", ")}, got #{on_conflict.inspect}"
41
77
  end
@@ -45,9 +81,10 @@ module Pgbus
45
81
  "[Pgbus] Batch on_discard: is deprecated and will be removed in 1.0 — use on_failure: instead"
46
82
  end
47
83
  end
84
+ self.class.warn_on_finish_deprecated if on_finish
48
85
 
49
86
  @batch_id = SecureRandom.uuid
50
- @on_finish = on_finish
87
+ @on_complete = on_complete || on_finish
51
88
  @on_success = on_success
52
89
  @on_failure = on_failure || on_discard
53
90
  @description = description
@@ -91,6 +128,23 @@ module Pgbus
91
128
  self
92
129
  end
93
130
 
131
+ # Enqueue ActiveJob instances (and anything the block enqueues) into this
132
+ # batch: +enqueue { ActiveJob.perform_all_later(jobs) }+. Instances keep
133
+ # their own +.set(queue:, wait:, priority:)+ options. On a started batch
134
+ # this is an open-batch add, so a job running inside the batch can call
135
+ # +batch.add(NextJob.new(id))+. Returns the jobs; on a unique batch that
136
+ # another run discarded (#discarded?) nothing was sent.
137
+ def add(*jobs, &block)
138
+ jobs = Builder.job_instances(jobs)
139
+ return jobs if jobs.empty? && block.nil?
140
+
141
+ enqueue do
142
+ ::ActiveJob.perform_all_later(jobs) if jobs.any?
143
+ yield if block
144
+ end
145
+ jobs
146
+ end
147
+
94
148
  # --- readers on a live batch ---------------------------------------
95
149
 
96
150
  def status = record&.status
@@ -210,11 +264,22 @@ module Pgbus
210
264
  end
211
265
 
212
266
  # Schema-probe memos (a true answer is kept for the life of the process)
213
- # and the warn-once flag, read and written under @schema_lock (issue
267
+ # and the warn-once flags, read and written under @schema_lock (issue
214
268
  # #527). The probes run outside it: a duplicate probe on a race is
215
269
  # harmless, a lock held across a query is not.
216
270
  @schema_lock = Mutex.new
217
271
 
272
+ # on_finish: is in most batch calls written before the rename (issue
273
+ # #538), so it warns once per process rather than on every batch.
274
+ def self.warn_on_finish_deprecated
275
+ first = @schema_lock.synchronize { @warned_on_finish ? false : (@warned_on_finish = true) }
276
+ return unless first
277
+
278
+ Pgbus.logger.warn do
279
+ "[Pgbus] Batch on_finish is deprecated and will be removed in 1.0 — use on_complete instead"
280
+ end
281
+ end
282
+
218
283
  def self.executions_migrated?
219
284
  return true if @schema_lock.synchronize { @executions_migrated }
220
285
 
@@ -243,6 +308,7 @@ module Pgbus
243
308
  @executions_migrated = nil
244
309
  @callback_jobs_migrated = nil
245
310
  @warned_callback_jobs_unmigrated = nil
311
+ @warned_on_finish = nil
246
312
  end
247
313
  end
248
314
 
@@ -521,7 +587,7 @@ module Pgbus
521
587
  @batch_id = record.batch_id
522
588
  @description = record.description
523
589
  @properties = parse_properties(record.properties)
524
- @on_finish = nil
590
+ @on_complete = nil
525
591
  @on_success = nil
526
592
  @on_failure = nil
527
593
  @uniqueness_key = nil
@@ -572,20 +638,37 @@ module Pgbus
572
638
  attrs = {
573
639
  batch_id: batch_id,
574
640
  description: description,
575
- on_finish_class: callback_class_name(on_finish),
576
- on_success_class: callback_class_name(on_success),
577
641
  properties: JSON.generate(properties),
578
642
  status: "pending"
579
643
  }
580
- if self.class.executions_migrated?
581
- attrs[:on_failure_class] = callback_class_name(on_failure)
582
- else
583
- attrs[:on_discard_class] = callback_class_name(on_failure)
584
- end
585
- attrs.merge!(callback_job_attributes) if self.class.callback_jobs_migrated?
644
+ CALLBACK_COLUMNS.each_key { |slot| attrs.merge!(callback_attributes(slot, public_send(slot))) }
586
645
  @record = BatchEntry.create!(attrs)
587
646
  end
588
647
 
648
+ # Replace one callback on a batch that has not finished yet (the
649
+ # builder's setters, inside the Pgbus.batch block, while the row is
650
+ # still pending and so cannot fire its callbacks).
651
+ def write_callback(slot, callback)
652
+ updated = BatchEntry.where(batch_id: batch_id, status: %w[pending processing])
653
+ .update_all(callback_attributes(slot, callback))
654
+ raise AlreadyFinished, "Can't change the callbacks of an already finished batch" if updated.zero?
655
+
656
+ instance_variable_set(:"@#{slot}", callback)
657
+ reload
658
+ end
659
+
660
+ # The pgbus_batches columns for one callback slot. Before the
661
+ # add_batch_executions migration the failure class lives in
662
+ # on_discard_class; before add_batch_callback_jobs there is no *_job
663
+ # column, and a configured instance degrades to its class.
664
+ def callback_attributes(slot, callback)
665
+ prefix = CALLBACK_COLUMNS.fetch(slot)
666
+ class_column = slot == :on_failure && !self.class.executions_migrated? ? "on_discard" : prefix
667
+ attrs = { "#{class_column}_class": callback_class_name(callback) }
668
+ attrs[:"#{prefix}_job"] = serialize_callback(callback) if self.class.callback_jobs_migrated?
669
+ attrs
670
+ end
671
+
589
672
  # A callback given as a bare class keeps the legacy *_class column; a
590
673
  # configured ActiveJob instance is serialized now (so .set options resolve
591
674
  # at creation, matching solid_queue) into the *_job jsonb column. Before
@@ -600,14 +683,6 @@ module Pgbus
600
683
  callback.class.name
601
684
  end
602
685
 
603
- def callback_job_attributes
604
- {
605
- on_finish_job: serialize_callback(on_finish),
606
- on_success_job: serialize_callback(on_success),
607
- on_failure_job: serialize_callback(on_failure)
608
- }
609
- end
610
-
611
686
  def serialize_callback(callback)
612
687
  return nil if callback.nil? || callback.is_a?(Class)
613
688
 
@@ -263,7 +263,7 @@ module Pgbus
263
263
  [:add_batch_executions]
264
264
  end
265
265
 
266
- # Configured callback instances (on_finish: MyJob.new.set(...)) need
266
+ # Configured callback instances (on_complete: MyJob.new.set(...)) need
267
267
  # somewhere to store the serialized job — issue #415.
268
268
  def batch_callback_jobs_migrations
269
269
  return [] unless table_exists?("pgbus_batches")
@@ -21,6 +21,17 @@ module Pgbus
21
21
  # payload: queue, job_class, msg_id, vt, extensions
22
22
  # pgbus.event_processed — event handler succeeded
23
23
  # pgbus.event_failed — event handler raised; carries :exception_object
24
+ # pgbus.event_dead_lettered — a consumer moved an event that exceeded max_retries
25
+ # to its DLQ; fires once, after the move
26
+ # payload: queue_name, routing_key, handler, event_id,
27
+ # read_ct, msg_id, error (the class of the last
28
+ # recorded failure: a handler's error, or the
29
+ # consumer's own parse error for a body it could
30
+ # not route; nil when none was recorded). Any of
31
+ # routing_key/handler/event_id can be nil: all
32
+ # three when the body is not a JSON object, a
33
+ # field the envelope lacks, and handler when no
34
+ # single subscriber in this process owns the queue
24
35
  # pgbus.event_unrouted — a consumer read an event from a queue no
25
36
  # subscriber in this process owns (stale topic
26
37
  # binding); the message is archived
@@ -60,6 +60,7 @@ module Pgbus
60
60
  subscribe("pgbus.job_dead_lettered") { |event| on_job_dead_lettered(event) },
61
61
  subscribe("pgbus.event_processed") { |event| on_event_processed(event) },
62
62
  subscribe("pgbus.event_failed") { |event| on_event_failed(event) },
63
+ subscribe("pgbus.event_dead_lettered") { |event| on_event_dead_lettered(event) },
63
64
  subscribe("pgbus.client.send_message") { |event| on_send_message(event) },
64
65
  subscribe("pgbus.client.send_batch") { |event| on_send_batch(event) },
65
66
  subscribe("pgbus.client.read_batch") { |event| on_read_batch(event) },
@@ -175,6 +176,20 @@ module Pgbus
175
176
  ::Appsignal.set_error(err) if err && ::Appsignal.respond_to?(:set_error)
176
177
  end
177
178
 
179
+ # 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
182
+ # that is not JSON leaves handler and routing_key nil; compact drops
183
+ # those tags, as Metrics::Subscriber does.
184
+ def on_event_dead_lettered(event)
185
+ payload = event.payload
186
+ ::Appsignal.increment_counter(
187
+ "#{METRIC_PREFIX}event_count",
188
+ 1,
189
+ { handler: payload[:handler], routing_key: payload[:routing_key], status: "dead_lettered" }.compact
190
+ )
191
+ end
192
+
178
193
  # ── Client (PGMQ wrapper) ───────────────────────────────────────
179
194
 
180
195
  def on_send_message(event)
@@ -63,6 +63,7 @@ module Pgbus
63
63
  subscribe("pgbus.job_visibility_extended") { |event| on_job_visibility_extended(event) },
64
64
  subscribe("pgbus.event_processed") { |event| on_event_processed(event) },
65
65
  subscribe("pgbus.event_failed") { |event| on_event_failed(event) },
66
+ subscribe("pgbus.event_dead_lettered") { |event| on_event_dead_lettered(event) },
66
67
  subscribe("pgbus.event_skipped") { |event| on_event_skipped(event) },
67
68
  subscribe("pgbus.client.send_message") { |event| on_send_message(event) },
68
69
  subscribe("pgbus.client.send_batch") { |event| on_send_batch(event) },
@@ -154,6 +155,17 @@ module Pgbus
154
155
  )
155
156
  end
156
157
 
158
+ # The consumer moved an event that exhausted max_retries to its DLQ
159
+ # (issue #543). A body that is not JSON leaves handler and routing_key
160
+ # nil; compact drops those tags.
161
+ def on_event_dead_lettered(event)
162
+ payload = event.payload
163
+ backend.increment(
164
+ "#{METRIC_PREFIX}event_count", 1,
165
+ compact(handler: payload[:handler], routing_key: payload[:routing_key], status: "dead_lettered")
166
+ )
167
+ end
168
+
157
169
  # An idempotent handler that did not run this delivery. `reason` is what
158
170
  # makes the skip readable: :completed/:cached is deduplication working,
159
171
  # :owned means a second delivery arrived while the holder was still
@@ -289,9 +289,9 @@ module Pgbus
289
289
  @jobs_processed.increment
290
290
  end
291
291
 
292
- # Order: read the last error, move with the dead-letter block, then
293
- # clear the failed-event row. A move that raises keeps the row, so the
294
- # next attempt still carries the reason.
292
+ # Order: read the last error, move with the dead-letter block, clear the
293
+ # failed-event row, then announce it. A move that raises keeps the row,
294
+ # so the next attempt still carries the reason, and announces nothing.
295
295
  def dead_letter(message, queue_name)
296
296
  last_error = FailedEventRecorder.last_error(queue_name: queue_name, msg_id: message.msg_id.to_i)
297
297
  headers = DeadLetterHeader.build(
@@ -301,6 +301,59 @@ module Pgbus
301
301
  )
302
302
  Pgbus.client.move_to_dead_letter(queue_name, message, headers: headers)
303
303
  FailedEventRecorder.clear!(queue_name: queue_name, msg_id: message.msg_id.to_i)
304
+ instrument_dead_lettered(message, queue_name, last_error)
305
+ end
306
+
307
+ # pgbus.event_dead_lettered, the event twin of the executor's
308
+ # pgbus.job_dead_lettered (issue #543). The dead-letter branch runs
309
+ # before handle_message parses the envelope, so it is parsed here: once
310
+ # per dead-lettered message, never on the success path. The move has
311
+ # already happened, so nothing here may turn the dead-letter into a
312
+ # failure (a failed stat, a circuit-breaker trip): the guard is the
313
+ # executor's instrument rescue, widened on purpose to cover building the
314
+ # payload as well as the subscribers, and logs at the same debug level.
315
+ def instrument_dead_lettered(message, queue_name, last_error)
316
+ envelope = dead_letter_envelope(message)
317
+ routing_key = envelope_routing_key(envelope)
318
+ Pgbus::Instrumentation.instrument(
319
+ "pgbus.event_dead_lettered",
320
+ queue_name: queue_name, routing_key: routing_key,
321
+ handler: owning_handler_name(routing_key, queue_name), event_id: envelope["event_id"],
322
+ read_ct: message.read_ct.to_i, msg_id: message.msg_id.to_i, error: last_error&.dig(:error_class)
323
+ )
324
+ rescue StandardError => e
325
+ Pgbus.logger.debug { "[Pgbus] Notification failure pgbus.event_dead_lettered: #{e.class}: #{e.message}" }
326
+ end
327
+
328
+ # The body that killed a message may not be a JSON object at all: PGMQ
329
+ # stores jsonb, so a producer writing straight to the queue can leave a
330
+ # scalar or an array, and the success path's dig raises on those every
331
+ # read. Any of them reads as an empty envelope, so the notification
332
+ # still fires.
333
+ def dead_letter_envelope(message)
334
+ raw = JSON.parse(message.message)
335
+ raw.is_a?(Hash) ? raw : {}
336
+ rescue JSON::ParserError, TypeError
337
+ {}
338
+ end
339
+
340
+ # The success path's lookup (headers first, then the top level),
341
+ # tolerating a headers value that is not an object.
342
+ def envelope_routing_key(envelope)
343
+ headers = envelope["headers"]
344
+ (headers["routing_key"] if headers.is_a?(Hash)) || envelope["routing_key"]
345
+ end
346
+
347
+ # The handler of the one subscriber that owns +queue_name+ in this
348
+ # process, or nil when there is none, no usable routing key, or more than
349
+ # one: #469 makes one owner per queue the convention, but Registry does
350
+ # not enforce it, and dispatch runs every owner, so naming one of several
351
+ # would misattribute the dead letter.
352
+ def owning_handler_name(routing_key, queue_name)
353
+ return unless routing_key.is_a?(String)
354
+
355
+ owners = @registry.handlers_for(routing_key, queue_name: queue_name)
356
+ owners.first.handler_class.name if owners.one?
304
357
  end
305
358
 
306
359
  # The executor's handle_failure, for events (issue #494): one upsert per
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.1"
4
+ VERSION = "0.19.2"
5
5
  end
data/lib/pgbus.rb CHANGED
@@ -298,6 +298,17 @@ module Pgbus
298
298
  Streams::Key.queue_name_budget
299
299
  end
300
300
 
301
+ # Open a batch, yield a Pgbus::Batch::Builder, and start the batch when
302
+ # the block returns — the top-level shortcut for Pgbus::Batch.start.
303
+ #
304
+ # Pgbus.batch(description: "Import") do |b|
305
+ # b.on_complete ImportDoneJob
306
+ # b.add(*rows.map { |row| ImportRowJob.new(row.id) })
307
+ # end
308
+ def batch(**, &)
309
+ Batch.start(**, &)
310
+ end
311
+
301
312
  # Publish an event to the bus — the top-level shortcut for
302
313
  # `Pgbus::EventBus::Publisher.publish`, symmetric with `Pgbus.stream`.
303
314
  #
data/sig/pgbus.rbs CHANGED
@@ -49,6 +49,13 @@ module Pgbus
49
49
  end
50
50
  end
51
51
 
52
+ # Partial: only the types Pgbus.batch names. Batch's own methods are not
53
+ # covered by spec/pgbus/rbs_spec.rb yet.
54
+ class Batch
55
+ class Builder
56
+ end
57
+ end
58
+
52
59
  def self.configuration: () -> Configuration
53
60
  def self.configure: () { (Configuration) -> void } -> Configuration
54
61
  def self.finalize!: () -> Configuration
@@ -57,6 +64,8 @@ module Pgbus
57
64
  def self.stream_key: (*untyped parts, ?digest_bits: Integer) -> String
58
65
  def self.stream_key!: (String key) -> String
59
66
  def self.stream_name_budget: () -> Integer
67
+ # Keywords go to Pgbus::Batch.new (description:, properties:, on_complete:, uniqueness_key:, …).
68
+ def self.batch: (**untyped options) { (Batch::Builder) -> void } -> Batch
60
69
  # delay: seconds, as a Numeric or an ActiveSupport::Duration (1.hour).
61
70
  def self.publish: (String routing_key, untyped payload, ?headers: Hash[String, untyped]?, ?delay: Numeric | _ToI) -> untyped
62
71
  def self.publish_later: (String routing_key, untyped payload, delay: Numeric | _ToI, ?headers: Hash[String, untyped]?) -> untyped
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.1
4
+ version: 0.19.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson
@@ -307,6 +307,7 @@ files:
307
307
  - lib/pgbus/active_job/executor.rb
308
308
  - lib/pgbus/autovacuum_tuning.rb
309
309
  - lib/pgbus/batch.rb
310
+ - lib/pgbus/batch/builder.rb
310
311
  - lib/pgbus/batch/sweep.rb
311
312
  - lib/pgbus/bus_record.rb
312
313
  - lib/pgbus/circuit_breaker.rb