pgbus 0.14.2 → 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.
@@ -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
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
@@ -54,6 +54,11 @@ module Pgbus
54
54
  raise ArgumentError,
55
55
  "Invalid group_mode: #{@group_mode.inspect}. Must be nil, :fifo, or :round_robin"
56
56
  end
57
+ if FairShare.enabled?(config) && (@group_mode || config.group_mode)
58
+ raise Pgbus::ConfigurationError,
59
+ "fair_share and group_mode are mutually exclusive — this worker has group_mode " \
60
+ "#{(@group_mode || config.group_mode).inspect} while config.fair_share is set"
61
+ end
57
62
  @single_active_consumer = single_active_consumer
58
63
  @consumer_priority = consumer_priority
59
64
  @lifecycle = Lifecycle.new
@@ -165,6 +170,7 @@ module Pgbus
165
170
  setup_signals
166
171
  start_heartbeat
167
172
  resolve_wildcard_queues
173
+ ensure_fair_indexes
168
174
  start_wake_source
169
175
  @lifecycle.transition_to!(:running)
170
176
  Pgbus.logger.info do
@@ -284,6 +290,8 @@ module Pgbus
284
290
  fetch_prioritized(active_queues, qty)
285
291
  elsif @group_mode
286
292
  fetch_grouped(active_queues, qty)
293
+ elsif fair_share_enabled?
294
+ fetch_fair(active_queues, qty)
287
295
  elsif active_queues.size == 1
288
296
  queue = active_queues.first
289
297
  messages = Pgbus.client.read_batch(queue, qty: qty) || []
@@ -391,6 +399,38 @@ module Pgbus
391
399
  results
392
400
  end
393
401
 
402
+ # Fair share (issue #426): one weighted-interleave read per queue, in
403
+ # list order with the remaining capacity — strict priority ACROSS queues
404
+ # (the capsule DSL contract), fair WITHIN each queue. Same loop shape as
405
+ # fetch_prioritized; under priority_levels the client composes both.
406
+ def fetch_fair(active_queues, qty)
407
+ remaining = qty
408
+ results = []
409
+
410
+ active_queues.each do |queue|
411
+ break if remaining <= 0
412
+
413
+ messages = Pgbus.client.read_batch_fair(queue, qty: remaining) || []
414
+ messages.each { |m| results << [queue, m] }
415
+ remaining -= messages.size
416
+ end
417
+
418
+ results
419
+ end
420
+
421
+ def fair_share_enabled?
422
+ FairShare.enabled?(config)
423
+ end
424
+
425
+ # Build the fair index on every queue this worker serves (idempotent,
426
+ # memoized in the client, CONCURRENTLY so a populated queue is never
427
+ # write-locked). Queues created later get theirs at creation time.
428
+ def ensure_fair_indexes
429
+ return unless fair_share_enabled?
430
+
431
+ queues.each { |q| Pgbus.client.ensure_fair_index(q) }
432
+ end
433
+
394
434
  def priority_enabled?
395
435
  config.priority_levels && config.priority_levels > 1
396
436
  end
@@ -448,6 +488,7 @@ module Pgbus
448
488
  return if @last_wildcard_resolve && (monotonic_now - @last_wildcard_resolve) < WILDCARD_REFRESH_INTERVAL
449
489
 
450
490
  resolve_wildcard_queues
491
+ ensure_fair_indexes
451
492
  end
452
493
 
453
494
  # When a "relation does not exist" error occurs, the queue was deleted.
data/lib/pgbus/testing.rb CHANGED
@@ -54,7 +54,8 @@ module Pgbus
54
54
  break unless event
55
55
 
56
56
  Pgbus::EventBus::Registry.instance.handlers_for(event.routing_key).each do |subscriber|
57
- subscriber.handler_class.new.handle(event)
57
+ # Restore the publisher's Current (issue #431) like the consumer does.
58
+ Pgbus::CurrentAttributes.restore(event.context) { subscriber.handler_class.new.handle(event) }
58
59
  end
59
60
 
60
61
  @mutex.synchronize { @events.shift }
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.14.2"
4
+ VERSION = "0.15.0"
5
5
  end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Pgbus
6
+ module Web
7
+ # Presents the Current attributes persisted in a job payload
8
+ # (Pgbus::CurrentAttributes::METADATA_KEY) for the dashboard's Context
9
+ # card: { "Current" => { "tenant" => "gid://app/Tenant/42", ... } }.
10
+ #
11
+ # Pure presentation: ActiveJob argument encodings are unwrapped to display
12
+ # strings (a GlobalID shows its gid, a serialized Symbol/Time its value) —
13
+ # nothing is constantized or located. Callers pass the payload through
14
+ # PayloadFilter first (ApplicationHelper#pgbus_parse_message does), so
15
+ # sensitive values arrive already redacted.
16
+ module JobContext
17
+ AJ_GLOBALID = "_aj_globalid"
18
+ AJ_SERIALIZED = "_aj_serialized"
19
+ AJ_META_PREFIX = "_aj_"
20
+
21
+ module_function
22
+
23
+ def from_payload(payload)
24
+ data = parse(payload)
25
+ stored = data && data[Pgbus::CurrentAttributes::METADATA_KEY]
26
+ return nil unless stored.is_a?(Hash) && stored.any?
27
+
28
+ stored.to_h do |klass_name, attrs|
29
+ [klass_name.to_s, present_attrs(attrs)]
30
+ end
31
+ end
32
+
33
+ def present_attrs(attrs)
34
+ return {} unless attrs.is_a?(Hash)
35
+
36
+ attrs.each_with_object({}) do |(name, value), out|
37
+ next if name.to_s.start_with?(AJ_META_PREFIX)
38
+
39
+ out[name.to_s] = display(value)
40
+ end
41
+ end
42
+
43
+ # String form of a value for the card. Hashes/arrays that are ActiveJob
44
+ # encodings collapse to their payload; anything else renders as JSON
45
+ # (stable across Ruby versions — Hash#inspect changed in 3.4).
46
+ def display(value)
47
+ unwrapped = unwrap(value)
48
+ unwrapped.is_a?(String) ? unwrapped : JSON.generate(unwrapped)
49
+ end
50
+
51
+ def unwrap(value)
52
+ case value
53
+ when Hash
54
+ return value[AJ_GLOBALID] if value.key?(AJ_GLOBALID)
55
+ return unwrap(value["value"]) if value.key?(AJ_SERIALIZED) && value.key?("value")
56
+
57
+ value.each_with_object({}) do |(k, v), out|
58
+ next if k.to_s.start_with?(AJ_META_PREFIX)
59
+
60
+ out[k] = unwrap(v)
61
+ end
62
+ when Array
63
+ value.map { |v| unwrap(v) }
64
+ else
65
+ value
66
+ end
67
+ end
68
+
69
+ def parse(payload)
70
+ case payload
71
+ when Hash then payload
72
+ when String then JSON.parse(payload)
73
+ end
74
+ rescue JSON::ParserError
75
+ nil
76
+ end
77
+ private_class_method :parse
78
+ end
79
+ end
80
+ end
data/lib/pgbus.rb CHANGED
@@ -49,6 +49,8 @@ module Pgbus
49
49
  # comes back with a msg_id count that doesn't match the number of jobs sent —
50
50
  # a data-integrity signal that some jobs may not have been persisted.
51
51
  class EnqueueError < Error; end
52
+ # A Current attribute could not be serialized for job persistence (issue #430).
53
+ class CurrentAttributesError < Error; end
52
54
 
53
55
  # Raised by the execution pools when work can't be accepted: the pool is
54
56
  # shutting down, or it's momentarily at capacity. Consumer-reachable during
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.14.2
4
+ version: 0.15.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson
@@ -257,6 +257,7 @@ files:
257
257
  - lib/pgbus.rb
258
258
  - lib/pgbus/active_job/adapter.rb
259
259
  - lib/pgbus/active_job/batch_id.rb
260
+ - lib/pgbus/active_job/current_attributes.rb
260
261
  - lib/pgbus/active_job/executor.rb
261
262
  - lib/pgbus/autovacuum_tuning.rb
262
263
  - lib/pgbus/batch.rb
@@ -269,6 +270,7 @@ files:
269
270
  - lib/pgbus/client.rb
270
271
  - lib/pgbus/client/connection_health.rb
271
272
  - lib/pgbus/client/ensure_stream_queue.rb
273
+ - lib/pgbus/client/fair_read.rb
272
274
  - lib/pgbus/client/notify_stream.rb
273
275
  - lib/pgbus/client/read_after.rb
274
276
  - lib/pgbus/client/resizable_pool.rb
@@ -277,6 +279,7 @@ files:
277
279
  - lib/pgbus/concurrency/semaphore.rb
278
280
  - lib/pgbus/configuration.rb
279
281
  - lib/pgbus/configuration/capsule_dsl.rb
282
+ - lib/pgbus/current_attributes.rb
280
283
  - lib/pgbus/database_tasks_guard.rb
281
284
  - lib/pgbus/dedicated_connection.rb
282
285
  - lib/pgbus/dedup_cache.rb
@@ -292,6 +295,7 @@ files:
292
295
  - lib/pgbus/execution_pools/async_pool.rb
293
296
  - lib/pgbus/execution_pools/thread_pool.rb
294
297
  - lib/pgbus/failed_event_recorder.rb
298
+ - lib/pgbus/fair_share.rb
295
299
  - lib/pgbus/generators/database_target_detector.rb
296
300
  - lib/pgbus/generators/migration_detector.rb
297
301
  - lib/pgbus/health_probe.rb
@@ -394,6 +398,7 @@ files:
394
398
  - lib/pgbus/web/data_source.rb
395
399
  - lib/pgbus/web/health_app.rb
396
400
  - lib/pgbus/web/health_server.rb
401
+ - lib/pgbus/web/job_context.rb
397
402
  - lib/pgbus/web/metrics_serializer.rb
398
403
  - lib/pgbus/web/payload_filter.rb
399
404
  - lib/pgbus/web/stream_app.rb