pgbus 0.7.8 → 0.8.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/README.md +42 -0
- data/app/helpers/pgbus/streams_helper.rb +3 -1
- data/lib/pgbus/active_job/executor.rb +31 -2
- data/lib/pgbus/client/notify_stream.rb +37 -0
- data/lib/pgbus/client.rb +2 -0
- data/lib/pgbus/configuration.rb +36 -1
- data/lib/pgbus/engine.rb +15 -0
- data/lib/pgbus/event_bus/handler.rb +22 -2
- data/lib/pgbus/instrumentation.rb +15 -6
- data/lib/pgbus/integrations/appsignal/dashboards/pgbus_health.json +87 -0
- data/lib/pgbus/integrations/appsignal/dashboards/pgbus_streams.json +65 -0
- data/lib/pgbus/integrations/appsignal/dashboards/pgbus_throughput.json +81 -0
- data/lib/pgbus/integrations/appsignal/probe.rb +128 -0
- data/lib/pgbus/integrations/appsignal/subscriber.rb +303 -0
- data/lib/pgbus/integrations/appsignal.rb +52 -0
- data/lib/pgbus/outbox.rb +17 -13
- data/lib/pgbus/process/dispatcher.rb +38 -0
- data/lib/pgbus/process/worker.rb +20 -2
- data/lib/pgbus/recurring/scheduler.rb +10 -2
- data/lib/pgbus/streams/turbo_broadcastable.rb +2 -1
- data/lib/pgbus/streams.rb +28 -7
- data/lib/pgbus/version.rb +1 -1
- data/lib/pgbus/web/data_source.rb +43 -4
- data/lib/pgbus/web/streamer/listener.rb +9 -5
- data/lib/pgbus/web/streamer/stream_event_dispatcher.rb +45 -21
- data/lib/pgbus.rb +7 -2
- metadata +8 -1
data/lib/pgbus/streams.rb
CHANGED
|
@@ -37,14 +37,19 @@ module Pgbus
|
|
|
37
37
|
class Stream
|
|
38
38
|
attr_reader :name
|
|
39
39
|
|
|
40
|
-
def initialize(streamables, client: Pgbus.client)
|
|
40
|
+
def initialize(streamables, client: Pgbus.client, durable: true)
|
|
41
41
|
@name = self.class.name_from(streamables)
|
|
42
42
|
self.class.validate_name_length!(@name, streamables)
|
|
43
43
|
@client = client
|
|
44
|
+
@durable = durable
|
|
44
45
|
@ensured = false
|
|
45
46
|
@ensure_mutex = Mutex.new
|
|
46
47
|
end
|
|
47
48
|
|
|
49
|
+
def durable?
|
|
50
|
+
@durable
|
|
51
|
+
end
|
|
52
|
+
|
|
48
53
|
# Broadcasts a Turbo Stream HTML payload through the pgbus streamer.
|
|
49
54
|
# PGMQ's `message` column is JSONB, so raw HTML strings can't be passed
|
|
50
55
|
# directly. We wrap as `{"html": "..."}` on the way in and unwrap in
|
|
@@ -69,15 +74,26 @@ module Pgbus
|
|
|
69
74
|
# through PGMQ; the predicate itself lives in-process on the
|
|
70
75
|
# subscriber side and is evaluated per-connection by the Dispatcher.
|
|
71
76
|
def broadcast(payload, visible_to: nil)
|
|
72
|
-
ensure_queue!
|
|
73
77
|
wrapped = { "html" => payload.to_s }
|
|
74
78
|
wrapped["visible_to"] = visible_to.to_s if visible_to
|
|
79
|
+
|
|
80
|
+
return broadcast_ephemeral(wrapped) unless @durable
|
|
81
|
+
|
|
82
|
+
ensure_queue!
|
|
75
83
|
transaction = current_open_transaction
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
84
|
+
instrument_payload = {
|
|
85
|
+
stream: @name,
|
|
86
|
+
visible_to: visible_to,
|
|
87
|
+
deferred: !transaction.nil?,
|
|
88
|
+
bytes: wrapped["html"].bytesize
|
|
89
|
+
}
|
|
90
|
+
Instrumentation.instrument("pgbus.stream.broadcast", instrument_payload) do
|
|
91
|
+
if transaction
|
|
92
|
+
transaction.after_commit { @client.send_message(@name, wrapped) }
|
|
93
|
+
nil
|
|
94
|
+
else
|
|
95
|
+
@client.send_message(@name, wrapped)
|
|
96
|
+
end
|
|
81
97
|
end
|
|
82
98
|
end
|
|
83
99
|
|
|
@@ -147,6 +163,11 @@ module Pgbus
|
|
|
147
163
|
|
|
148
164
|
private
|
|
149
165
|
|
|
166
|
+
def broadcast_ephemeral(wrapped)
|
|
167
|
+
@client.notify_stream(@name, wrapped)
|
|
168
|
+
nil
|
|
169
|
+
end
|
|
170
|
+
|
|
150
171
|
def ensure_queue!
|
|
151
172
|
return if @ensured
|
|
152
173
|
|
data/lib/pgbus/version.rb
CHANGED
|
@@ -43,11 +43,10 @@ module Pgbus
|
|
|
43
43
|
# different connection lifecycle than the worker processes).
|
|
44
44
|
def queues_with_metrics
|
|
45
45
|
queue_names = connection.select_values("SELECT queue_name FROM pgmq.meta ORDER BY queue_name")
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
# Array#include? cost in the loop was O(n²) per dashboard load.
|
|
46
|
+
return [] if queue_names.empty?
|
|
47
|
+
|
|
49
48
|
paused_queues = paused_queue_names.to_set
|
|
50
|
-
queue_names
|
|
49
|
+
batched_queue_metrics(queue_names).map do |q|
|
|
51
50
|
q.merge(paused: paused_queues.include?(logical_queue_name(q[:name])))
|
|
52
51
|
end
|
|
53
52
|
rescue StandardError => e
|
|
@@ -1008,6 +1007,46 @@ module Pgbus
|
|
|
1008
1007
|
rows.to_a.map { |r| format_message(r, r["queue_name"]) }
|
|
1009
1008
|
end
|
|
1010
1009
|
|
|
1010
|
+
def batched_queue_metrics(queue_names)
|
|
1011
|
+
return [] if queue_names.empty?
|
|
1012
|
+
|
|
1013
|
+
unions = queue_names.filter_map do |name|
|
|
1014
|
+
sanitized = sanitize_name(name)
|
|
1015
|
+
qtable = "q_#{sanitized}"
|
|
1016
|
+
seq_name = "#{qtable}_msg_id_seq"
|
|
1017
|
+
<<~SQL
|
|
1018
|
+
SELECT
|
|
1019
|
+
#{connection.quote(name)} AS queue_name,
|
|
1020
|
+
(SELECT count(*) FROM pgmq.#{qtable}) AS queue_length,
|
|
1021
|
+
(SELECT count(*) FROM pgmq.#{qtable} WHERE vt <= NOW()) AS queue_visible_length,
|
|
1022
|
+
(SELECT EXTRACT(epoch FROM (NOW() - max(enqueued_at)))::int FROM pgmq.#{qtable}) AS newest_msg_age_sec,
|
|
1023
|
+
(SELECT EXTRACT(epoch FROM (NOW() - min(enqueued_at)))::int FROM pgmq.#{qtable}) AS oldest_msg_age_sec,
|
|
1024
|
+
(SELECT CASE WHEN is_called THEN last_value ELSE 0 END FROM pgmq.#{seq_name}) AS total_messages
|
|
1025
|
+
SQL
|
|
1026
|
+
rescue StandardError => e
|
|
1027
|
+
Pgbus.logger.debug { "[Pgbus::Web] Skipping queue metrics for #{name}: #{e.message}" }
|
|
1028
|
+
nil
|
|
1029
|
+
end
|
|
1030
|
+
|
|
1031
|
+
return [] if unions.empty?
|
|
1032
|
+
|
|
1033
|
+
sql = unions.join(" UNION ALL ")
|
|
1034
|
+
rows = connection.select_all(sql, "Pgbus Batched Queue Metrics")
|
|
1035
|
+
rows.to_a.map do |row|
|
|
1036
|
+
{
|
|
1037
|
+
name: row["queue_name"],
|
|
1038
|
+
queue_length: row["queue_length"].to_i,
|
|
1039
|
+
queue_visible_length: row["queue_visible_length"].to_i,
|
|
1040
|
+
oldest_msg_age_sec: row["oldest_msg_age_sec"]&.to_i,
|
|
1041
|
+
newest_msg_age_sec: row["newest_msg_age_sec"]&.to_i,
|
|
1042
|
+
total_messages: row["total_messages"].to_i
|
|
1043
|
+
}
|
|
1044
|
+
end
|
|
1045
|
+
rescue StandardError => e
|
|
1046
|
+
Pgbus.logger.error { "[Pgbus::Web] Error fetching batched queue metrics: #{e.class}: #{e.message}" }
|
|
1047
|
+
[]
|
|
1048
|
+
end
|
|
1049
|
+
|
|
1011
1050
|
def queue_metrics_via_sql(queue_name)
|
|
1012
1051
|
qtable = "q_#{sanitize_name(queue_name)}"
|
|
1013
1052
|
seq_name = "#{qtable}_msg_id_seq"
|
|
@@ -29,7 +29,11 @@ module Pgbus
|
|
|
29
29
|
# For a queue named `pgbus_stream_chat` the trigger table is
|
|
30
30
|
# `q_pgbus_stream_chat`, so the channel is `pgmq.q_pgbus_stream_chat.INSERT`.
|
|
31
31
|
class Listener
|
|
32
|
-
WakeMessage = Data.define(:queue_name)
|
|
32
|
+
WakeMessage = Data.define(:queue_name, :payload) do
|
|
33
|
+
def initialize(queue_name:, payload: nil)
|
|
34
|
+
super
|
|
35
|
+
end
|
|
36
|
+
end
|
|
33
37
|
|
|
34
38
|
CHANNEL_PREFIX = "pgmq.q_"
|
|
35
39
|
CHANNEL_SUFFIX = ".INSERT"
|
|
@@ -110,8 +114,8 @@ module Pgbus
|
|
|
110
114
|
|
|
111
115
|
timeout_s = @health_check_ms / 1000.0
|
|
112
116
|
begin
|
|
113
|
-
@conn.wait_for_notify(timeout_s) do |channel, _pid,
|
|
114
|
-
handle_notify(channel)
|
|
117
|
+
@conn.wait_for_notify(timeout_s) do |channel, _pid, payload|
|
|
118
|
+
handle_notify(channel, payload)
|
|
115
119
|
end || run_health_check
|
|
116
120
|
rescue IOError => e
|
|
117
121
|
# #stop closes the PG connection to interrupt
|
|
@@ -182,11 +186,11 @@ module Pgbus
|
|
|
182
186
|
@listening_to.delete(channel)
|
|
183
187
|
end
|
|
184
188
|
|
|
185
|
-
def handle_notify(channel)
|
|
189
|
+
def handle_notify(channel, payload = nil)
|
|
186
190
|
queue_name = queue_name_from(channel)
|
|
187
191
|
return unless queue_name
|
|
188
192
|
|
|
189
|
-
@dispatch_queue << WakeMessage.new(queue_name: queue_name)
|
|
193
|
+
@dispatch_queue << WakeMessage.new(queue_name: queue_name, payload: payload)
|
|
190
194
|
end
|
|
191
195
|
|
|
192
196
|
def run_health_check
|
|
@@ -92,6 +92,7 @@ module Pgbus
|
|
|
92
92
|
# boolean assignment), the sentinel break would still fire.
|
|
93
93
|
@running = false
|
|
94
94
|
@thread = nil
|
|
95
|
+
@ephemeral_seq = 0
|
|
95
96
|
end
|
|
96
97
|
|
|
97
98
|
def start
|
|
@@ -136,7 +137,7 @@ module Pgbus
|
|
|
136
137
|
# queue's current contents — once we hit a non-Wake or a
|
|
137
138
|
# different stream, we stop and let the regular path handle
|
|
138
139
|
# the rest.
|
|
139
|
-
if msg.is_a?(WakeMessage)
|
|
140
|
+
if msg.is_a?(WakeMessage) && msg.payload.nil?
|
|
140
141
|
wakes, trailing = drain_wakes_for(msg)
|
|
141
142
|
wakes.each { |w| handle(w) }
|
|
142
143
|
handle(trailing) if trailing
|
|
@@ -165,7 +166,7 @@ module Pgbus
|
|
|
165
166
|
return [coalesced, nil] # queue drained
|
|
166
167
|
end
|
|
167
168
|
|
|
168
|
-
return [coalesced, peek] unless peek.is_a?(WakeMessage)
|
|
169
|
+
return [coalesced, peek] unless peek.is_a?(WakeMessage) && peek.payload.nil?
|
|
169
170
|
|
|
170
171
|
next if seen.include?(peek.queue_name)
|
|
171
172
|
|
|
@@ -193,32 +194,26 @@ module Pgbus
|
|
|
193
194
|
|
|
194
195
|
def handle_wake(msg)
|
|
195
196
|
started_at = monotonic_ms
|
|
196
|
-
# msg.queue_name is the PGMQ full table name (pgbus_int_pbns_xxx),
|
|
197
|
-
# but connections are registered under the logical name (pbns_xxx).
|
|
198
|
-
# Translate before looking up.
|
|
199
197
|
stream = @full_to_logical[msg.queue_name] || msg.queue_name
|
|
200
198
|
registered = @registry.connections_for(stream)
|
|
201
199
|
in_flight_pairs = @in_flight[stream]
|
|
202
200
|
return if registered.empty? && in_flight_pairs.empty?
|
|
203
201
|
|
|
202
|
+
if msg.payload
|
|
203
|
+
handle_ephemeral_wake(msg, stream, registered, in_flight_pairs, started_at)
|
|
204
|
+
else
|
|
205
|
+
handle_durable_wake(stream, registered, in_flight_pairs, started_at)
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def handle_durable_wake(stream, registered, in_flight_pairs, started_at)
|
|
204
210
|
min_seen = minimum_cursor(registered, in_flight_pairs)
|
|
205
211
|
raw_envelopes = @client.read_after(stream, after_id: min_seen, limit: @read_limit)
|
|
206
212
|
return if raw_envelopes.empty?
|
|
207
213
|
|
|
208
214
|
envelopes = raw_envelopes.map { |e| unwrap_stream_envelope(e) }
|
|
209
|
-
# The maximum msg_id in THIS batch. We advance every
|
|
210
|
-
# connection's scanned cursor past this value even if the
|
|
211
|
-
# filter drops everything — otherwise a 500-message run
|
|
212
|
-
# of invisible broadcasts would pin minimum_cursor and
|
|
213
|
-
# the dispatcher would re-read the same window forever,
|
|
214
|
-
# starving later public messages. Connection#enqueue still
|
|
215
|
-
# gates the client-facing cursor on actual successful
|
|
216
|
-
# writes, so this advance is invisible to clients.
|
|
217
215
|
max_msg_id = envelopes.map(&:msg_id).max
|
|
218
216
|
|
|
219
|
-
# Each connection gets a per-connection filtered subset. We
|
|
220
|
-
# can't pre-filter once because different connections have
|
|
221
|
-
# different authorize contexts.
|
|
222
217
|
registered.each do |conn|
|
|
223
218
|
safe_enqueue(conn, visible_envelopes_for(envelopes, conn))
|
|
224
219
|
advance_scanned_cursor(conn, max_msg_id)
|
|
@@ -230,11 +225,40 @@ module Pgbus
|
|
|
230
225
|
|
|
231
226
|
prune_dead(registered)
|
|
232
227
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
228
|
+
record_stat(
|
|
229
|
+
stream_name: stream,
|
|
230
|
+
event_type: "broadcast",
|
|
231
|
+
started_at: started_at,
|
|
232
|
+
fanout: registered.size + in_flight_pairs.size
|
|
233
|
+
)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def handle_ephemeral_wake(msg, stream, registered, in_flight_pairs, started_at)
|
|
237
|
+
parsed = JSON.parse(msg.payload)
|
|
238
|
+
html = parsed.is_a?(Hash) ? parsed["html"] : nil
|
|
239
|
+
return unless html.is_a?(String)
|
|
240
|
+
|
|
241
|
+
visible_to = parsed["visible_to"]
|
|
242
|
+
visible_to = visible_to.to_sym if visible_to.is_a?(String)
|
|
243
|
+
|
|
244
|
+
@ephemeral_seq += 1
|
|
245
|
+
envelope = StreamEnvelope.new(
|
|
246
|
+
msg_id: -@ephemeral_seq,
|
|
247
|
+
enqueued_at: Time.now.utc.iso8601(6),
|
|
248
|
+
payload: html,
|
|
249
|
+
source: "ephemeral",
|
|
250
|
+
visible_to: visible_to
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
registered.each do |conn|
|
|
254
|
+
safe_enqueue(conn, visible_envelopes_for([envelope], conn))
|
|
255
|
+
end
|
|
256
|
+
in_flight_pairs.each do |(conn, buffer)|
|
|
257
|
+
buffer.concat(visible_envelopes_for([envelope], conn))
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
prune_dead(registered)
|
|
261
|
+
|
|
238
262
|
record_stat(
|
|
239
263
|
stream_name: stream,
|
|
240
264
|
event_type: "broadcast",
|
data/lib/pgbus.rb
CHANGED
|
@@ -40,6 +40,10 @@ module Pgbus
|
|
|
40
40
|
loader.ignore("#{__dir__}/generators")
|
|
41
41
|
loader.ignore("#{__dir__}/active_job")
|
|
42
42
|
loader.ignore("#{__dir__}/pgbus/testing")
|
|
43
|
+
# Vendor integrations are loaded conditionally (when the vendor gem
|
|
44
|
+
# is present) by lib/pgbus/engine.rb. Keeping them out of Zeitwerk
|
|
45
|
+
# means we don't reference vendor constants at autoload time.
|
|
46
|
+
loader.ignore("#{__dir__}/pgbus/integrations")
|
|
43
47
|
# lib/puma/plugin/pgbus_streams.rb is a Puma plugin — it's required
|
|
44
48
|
# explicitly by the user from config/puma.rb via `plugin :pgbus_streams`.
|
|
45
49
|
# Without this ignore, Zeitwerk scans lib/puma/ under the pgbus loader
|
|
@@ -100,10 +104,11 @@ module Pgbus
|
|
|
100
104
|
# clears it. The cache key is the resolved name string, not the raw
|
|
101
105
|
# streamables, so `Pgbus.stream(@order)` and `Pgbus.stream(@order)`
|
|
102
106
|
# in the same process return the same instance.
|
|
103
|
-
def stream(streamables)
|
|
107
|
+
def stream(streamables, durable: true)
|
|
104
108
|
name = Streams::Stream.name_from(streamables)
|
|
109
|
+
cache_key = "#{name}:#{durable ? "d" : "e"}"
|
|
105
110
|
@stream_cache ||= Concurrent::Map.new
|
|
106
|
-
@stream_cache.compute_if_absent(
|
|
111
|
+
@stream_cache.compute_if_absent(cache_key) { Streams::Stream.new(streamables, durable: durable) }
|
|
107
112
|
end
|
|
108
113
|
|
|
109
114
|
# Compose a short, pgbus-safe stream identifier from any mix of
|
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.
|
|
4
|
+
version: 0.8.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Mikael Henriksson
|
|
@@ -250,6 +250,7 @@ files:
|
|
|
250
250
|
- lib/pgbus/cli.rb
|
|
251
251
|
- lib/pgbus/client.rb
|
|
252
252
|
- lib/pgbus/client/ensure_stream_queue.rb
|
|
253
|
+
- lib/pgbus/client/notify_stream.rb
|
|
253
254
|
- lib/pgbus/client/read_after.rb
|
|
254
255
|
- lib/pgbus/concurrency.rb
|
|
255
256
|
- lib/pgbus/concurrency/blocked_execution.rb
|
|
@@ -273,6 +274,12 @@ files:
|
|
|
273
274
|
- lib/pgbus/generators/database_target_detector.rb
|
|
274
275
|
- lib/pgbus/generators/migration_detector.rb
|
|
275
276
|
- lib/pgbus/instrumentation.rb
|
|
277
|
+
- lib/pgbus/integrations/appsignal.rb
|
|
278
|
+
- lib/pgbus/integrations/appsignal/dashboards/pgbus_health.json
|
|
279
|
+
- lib/pgbus/integrations/appsignal/dashboards/pgbus_streams.json
|
|
280
|
+
- lib/pgbus/integrations/appsignal/dashboards/pgbus_throughput.json
|
|
281
|
+
- lib/pgbus/integrations/appsignal/probe.rb
|
|
282
|
+
- lib/pgbus/integrations/appsignal/subscriber.rb
|
|
276
283
|
- lib/pgbus/log_formatter.rb
|
|
277
284
|
- lib/pgbus/outbox.rb
|
|
278
285
|
- lib/pgbus/outbox/poller.rb
|