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.
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
- if transaction
77
- transaction.after_commit { @client.send_message(@name, wrapped) }
78
- nil
79
- else
80
- @client.send_message(@name, wrapped)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pgbus
4
- VERSION = "0.7.8"
4
+ VERSION = "0.8.0"
5
5
  end
@@ -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
- # paused_queue_names returns an Array; convert to Set so the
47
- # per-queue membership check is O(1). With 100+ queues the
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.map { |name| queue_metrics_via_sql(name) }.compact.map do |q|
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, _payload|
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
- # Record one stat row per wake. Fanout is the number of
234
- # subscribers (registered + in-flight) that received the
235
- # broadcast before any filter dropped it — the "intended"
236
- # audience size, which is the useful operator number even
237
- # when audience filtering is in play.
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(name) { Streams::Stream.new(streamables) }
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.7.8
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