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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +35 -0
- data/README.md +33 -0
- data/Rakefile +6 -1
- data/app/helpers/pgbus/application_helper.rb +7 -0
- data/app/models/pgbus/batch_entry.rb +78 -6
- data/app/models/pgbus/batch_execution.rb +28 -0
- data/app/models/pgbus/blocked_execution.rb +2 -2
- data/app/models/pgbus/uniqueness_key.rb +50 -4
- data/app/views/pgbus/batches/_batches_table.html.erb +3 -3
- data/app/views/pgbus/batches/show.html.erb +7 -7
- data/app/views/pgbus/dead_letter/show.html.erb +17 -0
- data/app/views/pgbus/events/_pending_table.html.erb +21 -0
- data/app/views/pgbus/jobs/show.html.erb +17 -0
- data/config/locales/da.yml +5 -2
- data/config/locales/de.yml +5 -2
- data/config/locales/en.yml +5 -2
- data/config/locales/es.yml +5 -2
- data/config/locales/fi.yml +5 -2
- data/config/locales/fr.yml +5 -2
- data/config/locales/it.yml +5 -2
- data/config/locales/ja.yml +5 -2
- data/config/locales/nb.yml +5 -2
- data/config/locales/nl.yml +5 -2
- data/config/locales/pt.yml +5 -2
- data/config/locales/sv.yml +5 -2
- data/lib/generators/pgbus/add_batch_callback_jobs_generator.rb +47 -0
- data/lib/generators/pgbus/add_batch_executions_generator.rb +44 -0
- data/lib/generators/pgbus/templates/add_batch_callback_jobs.rb.erb +13 -0
- data/lib/generators/pgbus/templates/add_batch_executions.rb.erb +50 -0
- data/lib/generators/pgbus/templates/initializer.rb.erb +2 -0
- data/lib/generators/pgbus/templates/migration.rb.erb +23 -2
- data/lib/pgbus/active_job/adapter.rb +146 -28
- data/lib/pgbus/active_job/batch_id.rb +48 -0
- data/lib/pgbus/active_job/current_attributes.rb +52 -0
- data/lib/pgbus/active_job/executor.rb +45 -9
- data/lib/pgbus/batch/sweep.rb +163 -0
- data/lib/pgbus/batch.rb +448 -63
- data/lib/pgbus/client/fair_read.rb +187 -0
- data/lib/pgbus/client.rb +221 -18
- data/lib/pgbus/concurrency/blocked_execution.rb +14 -1
- data/lib/pgbus/configuration.rb +81 -1
- data/lib/pgbus/current_attributes.rb +156 -0
- data/lib/pgbus/engine.rb +2 -0
- data/lib/pgbus/event.rb +7 -2
- data/lib/pgbus/event_bus/handler.rb +12 -2
- data/lib/pgbus/event_bus/publisher.rb +37 -2
- data/lib/pgbus/event_bus/subscriber.rb +4 -0
- data/lib/pgbus/fair_share.rb +110 -0
- data/lib/pgbus/generators/migration_detector.rb +30 -0
- data/lib/pgbus/instrumentation.rb +5 -0
- data/lib/pgbus/outbox/poller.rb +6 -7
- data/lib/pgbus/outbox.rb +8 -1
- data/lib/pgbus/process/consumer.rb +38 -1
- data/lib/pgbus/process/dispatcher.rb +41 -17
- data/lib/pgbus/process/worker.rb +41 -0
- data/lib/pgbus/recurring/schedule.rb +12 -1
- data/lib/pgbus/testing.rb +2 -1
- data/lib/pgbus/uniqueness.rb +35 -7
- data/lib/pgbus/version.rb +1 -1
- data/lib/pgbus/web/data_source.rb +47 -9
- data/lib/pgbus/web/job_context.rb +80 -0
- data/lib/pgbus.rb +2 -0
- 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
data/lib/pgbus/event.rb
CHANGED
|
@@ -4,14 +4,19 @@ require "time"
|
|
|
4
4
|
|
|
5
5
|
module Pgbus
|
|
6
6
|
class Event
|
|
7
|
-
|
|
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
|
-
|
|
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
|
data/lib/pgbus/outbox/poller.rb
CHANGED
|
@@ -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,
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
|
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
|