pgbus 0.14.1 → 0.15.0

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 (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +35 -0
  3. data/README.md +33 -0
  4. data/Rakefile +6 -1
  5. data/app/helpers/pgbus/application_helper.rb +7 -0
  6. data/app/models/pgbus/batch_entry.rb +78 -6
  7. data/app/models/pgbus/batch_execution.rb +28 -0
  8. data/app/models/pgbus/blocked_execution.rb +2 -2
  9. data/app/models/pgbus/uniqueness_key.rb +50 -4
  10. data/app/views/pgbus/batches/_batches_table.html.erb +3 -3
  11. data/app/views/pgbus/batches/show.html.erb +7 -7
  12. data/app/views/pgbus/dead_letter/show.html.erb +17 -0
  13. data/app/views/pgbus/events/_pending_table.html.erb +21 -0
  14. data/app/views/pgbus/jobs/show.html.erb +17 -0
  15. data/config/locales/da.yml +5 -2
  16. data/config/locales/de.yml +5 -2
  17. data/config/locales/en.yml +5 -2
  18. data/config/locales/es.yml +5 -2
  19. data/config/locales/fi.yml +5 -2
  20. data/config/locales/fr.yml +5 -2
  21. data/config/locales/it.yml +5 -2
  22. data/config/locales/ja.yml +5 -2
  23. data/config/locales/nb.yml +5 -2
  24. data/config/locales/nl.yml +5 -2
  25. data/config/locales/pt.yml +5 -2
  26. data/config/locales/sv.yml +5 -2
  27. data/lib/generators/pgbus/add_batch_callback_jobs_generator.rb +47 -0
  28. data/lib/generators/pgbus/add_batch_executions_generator.rb +44 -0
  29. data/lib/generators/pgbus/templates/add_batch_callback_jobs.rb.erb +13 -0
  30. data/lib/generators/pgbus/templates/add_batch_executions.rb.erb +50 -0
  31. data/lib/generators/pgbus/templates/initializer.rb.erb +2 -0
  32. data/lib/generators/pgbus/templates/migration.rb.erb +23 -2
  33. data/lib/pgbus/active_job/adapter.rb +146 -28
  34. data/lib/pgbus/active_job/batch_id.rb +48 -0
  35. data/lib/pgbus/active_job/current_attributes.rb +52 -0
  36. data/lib/pgbus/active_job/executor.rb +45 -9
  37. data/lib/pgbus/batch/sweep.rb +163 -0
  38. data/lib/pgbus/batch.rb +448 -63
  39. data/lib/pgbus/client/fair_read.rb +187 -0
  40. data/lib/pgbus/client.rb +221 -18
  41. data/lib/pgbus/concurrency/blocked_execution.rb +14 -1
  42. data/lib/pgbus/configuration.rb +81 -1
  43. data/lib/pgbus/current_attributes.rb +156 -0
  44. data/lib/pgbus/engine.rb +2 -0
  45. data/lib/pgbus/event.rb +7 -2
  46. data/lib/pgbus/event_bus/handler.rb +12 -2
  47. data/lib/pgbus/event_bus/publisher.rb +37 -2
  48. data/lib/pgbus/event_bus/subscriber.rb +4 -0
  49. data/lib/pgbus/fair_share.rb +110 -0
  50. data/lib/pgbus/generators/migration_detector.rb +30 -0
  51. data/lib/pgbus/instrumentation.rb +5 -0
  52. data/lib/pgbus/outbox/poller.rb +6 -7
  53. data/lib/pgbus/outbox.rb +8 -1
  54. data/lib/pgbus/process/consumer.rb +38 -1
  55. data/lib/pgbus/process/dispatcher.rb +41 -17
  56. data/lib/pgbus/process/worker.rb +41 -0
  57. data/lib/pgbus/recurring/schedule.rb +12 -1
  58. data/lib/pgbus/testing.rb +2 -1
  59. data/lib/pgbus/uniqueness.rb +35 -7
  60. data/lib/pgbus/version.rb +1 -1
  61. data/lib/pgbus/web/data_source.rb +47 -9
  62. data/lib/pgbus/web/job_context.rb +80 -0
  63. data/lib/pgbus.rb +2 -0
  64. metadata +13 -1
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ # Persists ActiveSupport::CurrentAttributes across enqueue → perform
5
+ # (issue #430).
6
+ #
7
+ # `capture` snapshots the assigned attributes of every persisted class
8
+ # (config.current_attributes: :auto, an explicit list, or per-class
9
+ # only:/except: filters) serialized through ActiveJob::Arguments — so
10
+ # GlobalID models, Symbols, Times round-trip like job arguments and fall
11
+ # under the allowed_global_id_models allowlist on the way back. `restore`
12
+ # nests `klass.set(attrs)` for the duration of a block.
13
+ #
14
+ # The ActiveJob side (Pgbus::ActiveJob::CurrentAttributes) calls capture
15
+ # from `serialize` and restore around `perform_now`, so the hop works under
16
+ # the pgbus worker and under Rails' :test / :inline adapters alike.
17
+ module CurrentAttributes
18
+ METADATA_KEY = "pgbus_current"
19
+
20
+ @missing_warned = Concurrent::Map.new
21
+
22
+ class << self
23
+ def enabled?(config = Pgbus.configuration)
24
+ !config.current_attributes.nil?
25
+ end
26
+
27
+ # { "Current" => serialized_attrs, ... } or nil when there is nothing
28
+ # to persist. `override` is a per-job-class spec (false disables; an
29
+ # Array/Hash replaces the config's list for this job).
30
+ def capture(config = Pgbus.configuration, override: nil)
31
+ return nil if override == false
32
+ return nil unless override || enabled?(config)
33
+
34
+ captured = {}
35
+ persisted_specs(config, override: override).each do |spec|
36
+ klass = resolve_class(spec[:name]) or next
37
+ attrs = filter_attrs(klass.attributes, spec)
38
+ next if attrs.empty?
39
+
40
+ captured[klass.name] = serialize_attrs(klass, attrs)
41
+ end
42
+ captured.empty? ? nil : captured
43
+ end
44
+
45
+ # Set every stored class's attributes for the block; previous values
46
+ # come back afterwards (ActiveSupport::CurrentAttributes#set semantics).
47
+ def restore(stored, &block)
48
+ return yield if stored.nil? || stored.empty?
49
+
50
+ stored.reduce(block) do |inner, (name, attrs)|
51
+ klass = resolve_class(name)
52
+ next inner unless klass
53
+
54
+ values = deserialize_attrs(klass, attrs)
55
+ -> { klass.set(values) { inner.call } }
56
+ end.call
57
+ end
58
+
59
+ # Config/override value → nil | :auto | [{ name:, only:, except: }].
60
+ # Raises Pgbus::ConfigurationError for anything else.
61
+ def normalize(value)
62
+ case value
63
+ when nil then nil
64
+ when :auto then :auto
65
+ when Array then value.map { |entry| class_spec(entry, {}) }
66
+ when Hash then value.map { |entry, filters| class_spec(entry, filters) }
67
+ else
68
+ raise Pgbus::ConfigurationError,
69
+ "current_attributes must be nil, :auto, an Array of CurrentAttributes classes/names, " \
70
+ "or a Hash of class/name => { only: [...] } | { except: [...] } (got #{value.inspect})"
71
+ end
72
+ end
73
+
74
+ # Normalized [{ name:, only:, except: }] for the config (or override).
75
+ def persisted_specs(config = Pgbus.configuration, override: nil)
76
+ source = override.nil? ? config.current_attributes : normalize(override)
77
+ return [] if source.nil? || source == false
78
+ return source unless source == :auto
79
+
80
+ ActiveSupport::CurrentAttributes.descendants.filter_map do |klass|
81
+ { name: klass.name, only: nil, except: nil } if klass.name
82
+ end
83
+ end
84
+
85
+ private
86
+
87
+ def class_spec(entry, filters)
88
+ name = case entry
89
+ when Class, String then entry.to_s
90
+ else
91
+ raise Pgbus::ConfigurationError,
92
+ "current_attributes entries must be classes or class names (got #{entry.inspect})"
93
+ end
94
+ unless filters.is_a?(Hash) && (filters.keys - %i[only except]).empty? && filters.size <= 1
95
+ raise Pgbus::ConfigurationError,
96
+ "current_attributes filters for #{name} must be { only: [...] } or { except: [...] } (got #{filters.inspect})"
97
+ end
98
+
99
+ { name: name, only: filter_list(name, filters[:only], :only), except: filter_list(name, filters[:except], :except) }
100
+ end
101
+
102
+ def filter_list(name, list, kind)
103
+ return nil if list.nil?
104
+ unless list.is_a?(Array) && list.all? { |a| a.is_a?(Symbol) || a.is_a?(String) }
105
+ raise Pgbus::ConfigurationError,
106
+ "current_attributes #{kind}: for #{name} must be an Array of attribute names (got #{list.inspect})"
107
+ end
108
+
109
+ list.map(&:to_sym)
110
+ end
111
+
112
+ def resolve_class(name)
113
+ name.constantize
114
+ rescue NameError
115
+ @missing_warned.compute_if_absent(name) do
116
+ Pgbus.logger.warn { "[Pgbus] current_attributes: #{name} is not defined — skipping" }
117
+ true
118
+ end
119
+ nil
120
+ end
121
+
122
+ def filter_attrs(attrs, spec)
123
+ attrs = attrs.compact
124
+ attrs = attrs.slice(*spec[:only]) if spec[:only]
125
+ attrs = attrs.except(*spec[:except]) if spec[:except]
126
+ attrs
127
+ end
128
+
129
+ def serialize_attrs(klass, attrs)
130
+ ::ActiveJob::Arguments.serialize([attrs]).first
131
+ rescue ::ActiveJob::SerializationError, URI::Error
132
+ culprit, value = attrs.find do |_name, v|
133
+ ::ActiveJob::Arguments.serialize([v])
134
+ false
135
+ rescue ::ActiveJob::SerializationError, URI::Error
136
+ true
137
+ end
138
+ raise Pgbus::CurrentAttributesError,
139
+ "#{klass.name}##{culprit} (#{value.class}) cannot be serialized for job persistence — " \
140
+ "make it GlobalID/JSON-serializable or exclude it: " \
141
+ "config.current_attributes = { #{klass.name} => { except: [:#{culprit}] } }"
142
+ end
143
+
144
+ def deserialize_attrs(klass, attrs)
145
+ instance = klass.instance
146
+ known, unknown = attrs.partition { |name, _| name.start_with?("_aj_") || instance.respond_to?("#{name}=") }
147
+ unless unknown.empty?
148
+ Pgbus.logger.debug do
149
+ "[Pgbus] current_attributes: #{klass.name} no longer defines #{unknown.map(&:first).join(", ")} — dropped"
150
+ end
151
+ end
152
+ ::ActiveJob::Arguments.deserialize([known.to_h]).first
153
+ end
154
+ end
155
+ end
156
+ end
data/lib/pgbus/engine.rb CHANGED
@@ -74,6 +74,8 @@ module Pgbus
74
74
  ActiveSupport.on_load(:active_job) do
75
75
  include Pgbus::Concurrency
76
76
  include Pgbus::Uniqueness
77
+ include Pgbus::ActiveJob::BatchId
78
+ include Pgbus::ActiveJob::CurrentAttributes
77
79
  end
78
80
  end
79
81
 
data/lib/pgbus/event.rb CHANGED
@@ -4,14 +4,19 @@ require "time"
4
4
 
5
5
  module Pgbus
6
6
  class Event
7
- attr_reader :event_id, :payload, :published_at, :routing_key, :headers
7
+ # +context+ is the publisher's persisted ActiveSupport::CurrentAttributes
8
+ # (issue #431) in their stored form — { "Current" => serialized attrs } —
9
+ # or nil. Handlers normally just read +Current+ (it is restored around
10
+ # +handle+); the raw form is here for tests and explicit access.
11
+ attr_reader :event_id, :payload, :published_at, :routing_key, :headers, :context
8
12
 
9
- def initialize(event_id:, payload:, published_at: nil, routing_key: nil, headers: nil)
13
+ def initialize(event_id:, payload:, published_at: nil, routing_key: nil, headers: nil, context: nil)
10
14
  @event_id = event_id
11
15
  @payload = payload
12
16
  @published_at = published_at || Time.now.utc
13
17
  @routing_key = routing_key
14
18
  @headers = headers || {}
19
+ @context = context
15
20
  end
16
21
 
17
22
  def [](key)
@@ -43,7 +43,10 @@ module Pgbus
43
43
  msg_id: message.msg_id.to_i
44
44
  }
45
45
  Instrumentation.instrument("pgbus.event_processed", instrument_payload) do
46
- handle(event)
46
+ # Publisher's Current attributes (issue #431) are set for the handler
47
+ # and reverted after (CurrentAttributes#set semantics); the Rails
48
+ # executor wrap above additionally resets at completion.
49
+ Pgbus::CurrentAttributes.restore(event.context) { handle(event) }
47
50
  end
48
51
  complete_claim!(event.event_id) if self.class.idempotent?
49
52
  :handled
@@ -88,10 +91,17 @@ module Pgbus
88
91
  payload = raw["payload"]
89
92
  payload = Serializer.locate_global_id(payload["_global_id"]) if payload.is_a?(Hash) && payload["_global_id"]
90
93
 
94
+ # Same allowlist boundary as Serializer.deserialize_job_data: every
95
+ # _aj_globalid in the persisted context is checked BEFORE anything is
96
+ # located, so a crafted envelope cannot load an arbitrary model.
97
+ context = raw[Pgbus::CurrentAttributes::METADATA_KEY]
98
+ Serializer.assert_job_global_ids_allowed!(context) if context
99
+
91
100
  Event.new(
92
101
  event_id: raw["event_id"],
93
102
  payload: payload,
94
- published_at: raw["published_at"] ? Time.parse(raw["published_at"]) : nil
103
+ published_at: raw["published_at"] ? Time.parse(raw["published_at"]) : nil,
104
+ context: context
95
105
  )
96
106
  end
97
107
 
@@ -9,6 +9,8 @@ module Pgbus
9
9
 
10
10
  def publish(routing_key, payload, headers: nil, delay: 0)
11
11
  event_data = build_event_data(payload, routing_key: routing_key)
12
+ event_data = tag_fair_share(event_data, payload, routing_key: routing_key, headers: headers)
13
+ event_data = tag_current(event_data)
12
14
 
13
15
  if defined?(Pgbus::Testing) && !Pgbus::Testing.disabled?
14
16
  event = Pgbus::Event.new(
@@ -16,14 +18,15 @@ module Pgbus
16
18
  payload: event_data["payload"],
17
19
  published_at: event_data["published_at"] ? Time.parse(event_data["published_at"]) : nil,
18
20
  routing_key: routing_key,
19
- headers: headers
21
+ headers: headers,
22
+ context: event_data[Pgbus::CurrentAttributes::METADATA_KEY]
20
23
  )
21
24
 
22
25
  Pgbus::Testing.store.push_event(event)
23
26
 
24
27
  if Pgbus::Testing.inline? && delay.to_i <= 0
25
28
  Pgbus::EventBus::Registry.instance.handlers_for(routing_key).each do |subscriber|
26
- subscriber.handler_class.new.handle(event)
29
+ Pgbus::CurrentAttributes.restore(event.context) { subscriber.handler_class.new.handle(event) }
27
30
  end
28
31
  end
29
32
 
@@ -37,6 +40,38 @@ module Pgbus
37
40
  publish(routing_key, payload, headers: headers, delay: delay)
38
41
  end
39
42
 
43
+ # Fair share for consumers (issue #427): when config.event_fair_share is
44
+ # set, hand the callable a Pgbus::Event (routing key, the payload object
45
+ # as passed to publish — not its serialized form — and headers) and merge
46
+ # the resolved key/weight into the envelope. Shared with Outbox.publish_event
47
+ # so the key is resolved where the publisher's context (Current.*) exists
48
+ # and rides the outbox row to the bus. Returns event_data itself when off.
49
+ def tag_fair_share(event_data, payload, routing_key:, headers:)
50
+ return event_data unless FairShare.event_enabled?
51
+
52
+ event = Pgbus::Event.new(
53
+ event_id: event_data["event_id"],
54
+ payload: payload,
55
+ routing_key: routing_key,
56
+ headers: headers
57
+ )
58
+ FairShare.inject_event_metadata(event, event_data)
59
+ end
60
+
61
+ # Current attributes publish → handler (issue #431): when
62
+ # config.current_attributes is set, snapshot the publisher's persisted
63
+ # Current classes (Pgbus::CurrentAttributes.capture — same filters,
64
+ # serialization and allowlist gating as jobs) into the envelope under
65
+ # the same +pgbus_current+ key jobs use. Shared with Outbox.publish_event
66
+ # so the capture happens where Current is set, not at relay time.
67
+ # Returns event_data itself when off or nothing is assigned.
68
+ def tag_current(event_data)
69
+ captured = Pgbus::CurrentAttributes.capture
70
+ return event_data unless captured
71
+
72
+ event_data.merge(Pgbus::CurrentAttributes::METADATA_KEY => captured)
73
+ end
74
+
40
75
  def build_event_data(payload, routing_key: nil)
41
76
  event_id = SecureRandom.uuid
42
77
 
@@ -13,6 +13,10 @@ module Pgbus
13
13
 
14
14
  def setup!
15
15
  Pgbus.client.ensure_queue(queue_name)
16
+ # Subscriber queues are created here, not by a worker, so this is their
17
+ # creation hook for the fair-share index (issue #427). Idempotent and
18
+ # memoized per process; instant on a fresh table, CONCURRENTLY otherwise.
19
+ Pgbus.client.ensure_fair_index(queue_name) if FairShare.event_enabled?
16
20
  Pgbus.client.bind_topic(pattern, queue_name)
17
21
  end
18
22
 
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ # Fair share scheduling across tenants (issue #426).
5
+ #
6
+ # At enqueue time the configured callable (`config.fair_share`) resolves a
7
+ # key — typically a tenant id — and an optional weight for the job. Both
8
+ # ride inside the job payload hash (same pattern as Concurrency::METADATA_KEY
9
+ # and Batch::METADATA_KEY), so they survive every path that re-sends a
10
+ # payload: blocked-execution promotion, DLQ retry, dashboard retry, bulk
11
+ # enqueue. On the read side Client#read_batch_fair interleaves across keys
12
+ # proportionally to weight (see Client::FairRead).
13
+ #
14
+ # Events (issue #427) use the same keys and the same read primitive: the
15
+ # configured `config.event_fair_share` callable receives the Pgbus::Event at
16
+ # publish time and the key rides in the event *envelope* (a sibling of
17
+ # event_id / payload / published_at — never inside the user payload), so the
18
+ # same `message->>'pgbus_fair_key'` expression, index and read SQL serve both
19
+ # jobs and events, and the outbox (which stores the envelope) carries it.
20
+ module FairShare
21
+ METADATA_KEY = "pgbus_fair_key"
22
+ WEIGHT_KEY = "pgbus_fair_weight"
23
+ DEFAULT_WEIGHT = 1
24
+
25
+ class << self
26
+ def enabled?(config = Pgbus.configuration)
27
+ !config.fair_share.nil?
28
+ end
29
+
30
+ def event_enabled?(config = Pgbus.configuration)
31
+ !config.event_fair_share.nil?
32
+ end
33
+
34
+ # Returns the payload with fair-share metadata merged in, or the payload
35
+ # itself (same object) when fair share is off or the job is unkeyed.
36
+ # Exceptions raised by the callable propagate — a key resolver that
37
+ # cannot run is a programmer error, not something to swallow at enqueue.
38
+ def inject_metadata(active_job, payload_hash, config = Pgbus.configuration)
39
+ return payload_hash unless enabled?(config)
40
+
41
+ tag(payload_hash, resolve(active_job, config))
42
+ end
43
+
44
+ # Event twin of inject_metadata: returns the event envelope (the hash
45
+ # Publisher.build_event_data produced) with fair-share metadata merged
46
+ # in, or the envelope itself (same object) when event fair share is off
47
+ # or the callable declines to key the event.
48
+ def inject_event_metadata(event, event_data, config = Pgbus.configuration)
49
+ return event_data unless event_enabled?(config)
50
+
51
+ tag(event_data, resolve_event(event, config))
52
+ end
53
+
54
+ # [key, weight] for the job, or nil when the callable declines to key it.
55
+ def resolve(active_job, config = Pgbus.configuration)
56
+ normalize(config.fair_share.call(active_job))
57
+ end
58
+
59
+ # [key, weight] for the event, or nil when the callable declines.
60
+ def resolve_event(event, config = Pgbus.configuration)
61
+ normalize(config.event_fair_share.call(event))
62
+ end
63
+
64
+ def extract_key(payload)
65
+ payload[METADATA_KEY]
66
+ end
67
+
68
+ def extract_weight(payload)
69
+ payload[WEIGHT_KEY] || DEFAULT_WEIGHT
70
+ end
71
+
72
+ private
73
+
74
+ def tag(hash, resolved)
75
+ return hash unless resolved
76
+
77
+ key, weight = resolved
78
+ tagged = hash.merge(METADATA_KEY => key)
79
+ tagged[WEIGHT_KEY] = weight unless weight == DEFAULT_WEIGHT
80
+ tagged
81
+ end
82
+
83
+ def normalize(result)
84
+ return nil if result.nil?
85
+
86
+ raw_key, raw_weight = result.is_a?(Array) ? result : [result, nil]
87
+ [normalize_key(raw_key), normalize_weight(raw_weight)]
88
+ end
89
+
90
+ def normalize_key(raw)
91
+ key = case raw
92
+ when String, Symbol, Integer then raw.to_s
93
+ else
94
+ raise ArgumentError,
95
+ "fair_share key must be a String, Symbol, or Integer (got #{raw.class})"
96
+ end
97
+ raise ArgumentError, "fair_share key must not be empty" if key.empty?
98
+
99
+ key
100
+ end
101
+
102
+ def normalize_weight(raw)
103
+ return DEFAULT_WEIGHT if raw.nil?
104
+ return raw if raw.is_a?(Numeric) && raw.positive?
105
+
106
+ raise ArgumentError, "fair_share weight must be a positive Numeric (got #{raw.inspect})"
107
+ end
108
+ end
109
+ end
110
+ end
@@ -78,6 +78,8 @@ module Pgbus
78
78
  add_recurring: "pgbus:add_recurring",
79
79
  add_failed_events_index: "pgbus:add_failed_events_index",
80
80
  add_processed_event_completion: "pgbus:add_processed_event_completion",
81
+ add_batch_executions: "pgbus:add_batch_executions",
82
+ add_batch_callback_jobs: "pgbus:add_batch_callback_jobs",
81
83
  tune_autovacuum: "pgbus:tune_autovacuum",
82
84
  tune_fillfactor: "pgbus:tune_fillfactor"
83
85
  }.freeze
@@ -97,6 +99,8 @@ module Pgbus
97
99
  add_recurring: "recurring tasks + executions tables",
98
100
  add_failed_events_index: "unique index on pgbus_failed_events (queue_name, msg_id)",
99
101
  add_processed_event_completion: "completed_at on pgbus_processed_events (two-phase idempotency claim)",
102
+ add_batch_executions: "batch execution-row tracking (self-healing completion, on_failure rename)",
103
+ add_batch_callback_jobs: "batch callback-job columns (configured ActiveJob instances as batch callbacks)",
100
104
  tune_autovacuum: "autovacuum tuning for PGMQ queue and archive tables",
101
105
  tune_fillfactor: "fillfactor=70 on PGMQ queue tables (reduces page density during update churn)"
102
106
  }.freeze
@@ -123,6 +127,8 @@ module Pgbus
123
127
  *recurring_migrations,
124
128
  *failed_events_index_migrations,
125
129
  *processed_event_completion_migrations,
130
+ *batch_executions_migrations,
131
+ *batch_callback_jobs_migrations,
126
132
  *autovacuum_migrations,
127
133
  *fillfactor_migrations
128
134
  ]
@@ -223,6 +229,30 @@ module Pgbus
223
229
  [:add_processed_event_completion]
224
230
  end
225
231
 
232
+ # Execution-row tracking plus the on_discard → on_failure column rename
233
+ # ship as one upgrade. Either a missing executions table or leftover
234
+ # pre-rename columns means this generator still needs to run.
235
+ def batch_executions_migrations
236
+ return [] unless table_exists?("pgbus_batches")
237
+
238
+ batches_columns = column_names("pgbus_batches")
239
+ migrated = table_exists?("pgbus_batch_executions") &&
240
+ batches_columns.include?("on_failure_class") &&
241
+ !batches_columns.include?("discarded_jobs")
242
+ return [] if migrated
243
+
244
+ [:add_batch_executions]
245
+ end
246
+
247
+ # Configured callback instances (on_finish: MyJob.new.set(...)) need
248
+ # somewhere to store the serialized job — issue #415.
249
+ def batch_callback_jobs_migrations
250
+ return [] unless table_exists?("pgbus_batches")
251
+ return [] if column_names("pgbus_batches").include?("on_finish_job")
252
+
253
+ [:add_batch_callback_jobs]
254
+ end
255
+
226
256
  # Autovacuum tuning: check if any PGMQ queue table already has
227
257
  # custom autovacuum settings applied. If not, queue the migration.
228
258
  def autovacuum_migrations
@@ -25,6 +25,11 @@ module Pgbus
25
25
  # pgbus.consumer.recycle — consumer hit a recycle threshold (same payload shape as pgbus.worker.recycle)
26
26
  # pgbus.serializer.serialize — job/event serialization
27
27
  # pgbus.serializer.deserialize — job/event deserialization
28
+ # pgbus.batch_finished — batch flipped to finished
29
+ # payload: batch_id, total_jobs, completed_jobs, failed_jobs
30
+ # pgbus.batch_sweep — dispatcher stalled-batch sweep
31
+ # payload: stalled_for, stale_executions, orphan_rows,
32
+ # started_batches, finished_batches
28
33
  #
29
34
  module Instrumentation
30
35
  module_function
@@ -102,12 +102,12 @@ module Pgbus
102
102
  return 0 if entries.empty?
103
103
 
104
104
  succeeded = 0
105
- entries.group_by { |e| [e.queue_name, e.priority, e.delay || 0] }.each do |(queue, _priority, delay), group|
105
+ entries.group_by { |e| [e.queue_name, e.priority, e.delay || 0] }.each do |(queue, priority, delay), group|
106
106
  payloads = group.map(&:payload)
107
107
  headers = group.map(&:headers)
108
108
  headers = nil if headers.all?(&:blank?)
109
109
 
110
- Pgbus.client.send_batch(queue, payloads, headers: headers, delay: delay)
110
+ Pgbus.client.send_batch(queue, payloads, headers: headers, delay: delay, priority: priority)
111
111
  now = Time.current
112
112
  group.each { |e| e.update!(published_at: now) }
113
113
  succeeded += group.size
@@ -119,16 +119,15 @@ module Pgbus
119
119
  succeeded
120
120
  end
121
121
 
122
- # Fallback for individual publishing when a batch fails.
123
- # Intentionally omits priority to match send_batch routing behavior
124
- # (base queue only), ensuring consistent queue placement regardless
125
- # of whether the batch or fallback path is used.
122
+ # Fallback for individual publishing when a batch fails. Passes the same
123
+ # priority as the batch path so queue placement is identical either way.
126
124
  def publish_single_queue(entry)
127
125
  Pgbus.client.send_message(
128
126
  entry.queue_name,
129
127
  entry.payload,
130
128
  headers: entry.headers,
131
- delay: entry.delay || 0
129
+ delay: entry.delay || 0,
130
+ priority: entry.priority
132
131
  )
133
132
  entry.update!(published_at: Time.current)
134
133
  true
data/lib/pgbus/outbox.rb CHANGED
@@ -18,7 +18,14 @@ module Pgbus
18
18
 
19
19
  def publish_event(routing_key, payload, headers: nil)
20
20
  Instrumentation.instrument("pgbus.outbox.publish", routing_key: routing_key, kind: :event) do
21
- event_data = EventBus::Publisher.build_event_data(payload)
21
+ # routing_key: in the envelope so the consumer can dispatch the relayed
22
+ # event to handlers — pgmq.send_topic matches queues at relay time but
23
+ # stamps nothing on the message, and Consumer#handle_message reads the
24
+ # routing key from the envelope (fixed alongside issue #431; previously
25
+ # relayed outbox events matched zero handlers and were archived unrun).
26
+ event_data = EventBus::Publisher.build_event_data(payload, routing_key: routing_key)
27
+ event_data = EventBus::Publisher.tag_fair_share(event_data, payload, routing_key: routing_key, headers: headers)
28
+ event_data = EventBus::Publisher.tag_current(event_data)
22
29
  OutboxEntry.create!(
23
30
  routing_key: routing_key,
24
31
  payload: event_data,
@@ -116,6 +116,7 @@ module Pgbus
116
116
  setup_signals
117
117
  start_heartbeat
118
118
  setup_subscriptions
119
+ ensure_fair_indexes
119
120
  start_wake_source
120
121
  Pgbus.logger.info do
121
122
  "[Pgbus] Consumer started: topics=#{topics.join(",")} threads=#{threads} " \
@@ -188,7 +189,9 @@ module Pgbus
188
189
  active_queues = @queue_names.reject { |q| @circuit_breaker.paused?(q) }
189
190
  return [] if active_queues.empty?
190
191
 
191
- if active_queues.size == 1
192
+ if fair_share_enabled?
193
+ fetch_fair(active_queues, qty)
194
+ elsif active_queues.size == 1
192
195
  queue = active_queues.first
193
196
  (Pgbus.client.read_batch(queue, qty: qty) || []).map { |m| [queue, m] }
194
197
  else
@@ -282,6 +285,40 @@ module Pgbus
282
285
  end
283
286
  end
284
287
 
288
+ # Fair share for consumers (issue #427): one weighted-interleave read per
289
+ # subscriber queue, in list order with the remaining capacity — strict
290
+ # order across queues, fair across tenants WITHIN each queue. Same loop
291
+ # shape as Worker#fetch_fair; the consumer has no priority levels or
292
+ # group mode, so this is the whole fair branch.
293
+ def fetch_fair(active_queues, qty)
294
+ remaining = qty
295
+ results = []
296
+
297
+ active_queues.each do |queue|
298
+ break if remaining <= 0
299
+
300
+ messages = Pgbus.client.read_batch_fair(queue, qty: remaining) || []
301
+ messages.each { |m| results << [queue, m] }
302
+ remaining -= messages.size
303
+ end
304
+
305
+ results
306
+ end
307
+
308
+ def fair_share_enabled?
309
+ FairShare.event_enabled?(config)
310
+ end
311
+
312
+ # Build the fair index on every subscriber queue this consumer reads
313
+ # (idempotent, memoized in the client, CONCURRENTLY so a populated queue
314
+ # is never write-locked). Subscriber queues created later get theirs from
315
+ # Subscriber#setup!.
316
+ def ensure_fair_indexes
317
+ return unless fair_share_enabled?
318
+
319
+ @queue_names.each { |q| Pgbus.client.ensure_fair_index(q) }
320
+ end
321
+
285
322
  # Signal the loop to exit cleanly once a recycle limit is hit. The clean
286
323
  # exit gets an immediate supervisor restart (supervisor.rb:305-307), so a
287
324
  # fresh fork replaces this one before its memory grows unbounded — the