pgbus 0.5.1 → 0.6.1

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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +244 -1
  3. data/Rakefile +8 -1
  4. data/app/controllers/pgbus/insights_controller.rb +6 -0
  5. data/app/helpers/pgbus/streams_helper.rb +115 -0
  6. data/app/javascript/pgbus/stream_source_element.js +212 -0
  7. data/app/models/pgbus/stream_stat.rb +123 -0
  8. data/app/views/pgbus/insights/show.html.erb +59 -0
  9. data/config/locales/en.yml +16 -0
  10. data/config/routes.rb +11 -0
  11. data/lib/generators/pgbus/add_job_stats_queue_index_generator.rb +53 -0
  12. data/lib/generators/pgbus/add_presence_generator.rb +55 -0
  13. data/lib/generators/pgbus/add_stream_stats_generator.rb +54 -0
  14. data/lib/generators/pgbus/templates/add_job_stats_latency.rb.erb +4 -1
  15. data/lib/generators/pgbus/templates/add_job_stats_queue_index.rb.erb +11 -0
  16. data/lib/generators/pgbus/templates/add_presence.rb.erb +26 -0
  17. data/lib/generators/pgbus/templates/add_stream_stats.rb.erb +18 -0
  18. data/lib/generators/pgbus/update_generator.rb +176 -23
  19. data/lib/pgbus/client/ensure_stream_queue.rb +54 -0
  20. data/lib/pgbus/client/read_after.rb +100 -0
  21. data/lib/pgbus/client.rb +6 -0
  22. data/lib/pgbus/configuration.rb +65 -0
  23. data/lib/pgbus/engine.rb +31 -0
  24. data/lib/pgbus/generators/config_converter.rb +22 -2
  25. data/lib/pgbus/generators/database_target_detector.rb +94 -0
  26. data/lib/pgbus/generators/migration_detector.rb +217 -0
  27. data/lib/pgbus/process/dispatcher.rb +62 -4
  28. data/lib/pgbus/streams/cursor.rb +71 -0
  29. data/lib/pgbus/streams/envelope.rb +58 -0
  30. data/lib/pgbus/streams/filters.rb +98 -0
  31. data/lib/pgbus/streams/presence.rb +216 -0
  32. data/lib/pgbus/streams/signed_name.rb +69 -0
  33. data/lib/pgbus/streams/turbo_broadcastable.rb +53 -0
  34. data/lib/pgbus/streams/watermark_cache_middleware.rb +28 -0
  35. data/lib/pgbus/streams.rb +151 -0
  36. data/lib/pgbus/version.rb +1 -1
  37. data/lib/pgbus/web/data_source.rb +88 -10
  38. data/lib/pgbus/web/stream_app.rb +179 -0
  39. data/lib/pgbus/web/streamer/connection.rb +122 -0
  40. data/lib/pgbus/web/streamer/dispatcher.rb +467 -0
  41. data/lib/pgbus/web/streamer/heartbeat.rb +105 -0
  42. data/lib/pgbus/web/streamer/instance.rb +176 -0
  43. data/lib/pgbus/web/streamer/io_writer.rb +73 -0
  44. data/lib/pgbus/web/streamer/listener.rb +228 -0
  45. data/lib/pgbus/web/streamer/registry.rb +103 -0
  46. data/lib/pgbus/web/streamer.rb +53 -0
  47. data/lib/pgbus.rb +28 -0
  48. data/lib/puma/plugin/pgbus_streams.rb +54 -0
  49. data/lib/tasks/pgbus_streams.rake +52 -0
  50. metadata +33 -1
@@ -0,0 +1,216 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module Streams
5
+ # Presence tracking for SSE streams. A "presence member" is an
6
+ # arbitrary identifier (typically a user id) marked as currently
7
+ # subscribed to a given stream. Members live in pgbus_presence_members
8
+ # with a (stream_name, member_id) primary key and a `last_seen_at`
9
+ # timestamp; the table is the source of truth across all Puma workers
10
+ # and Falcon reactors.
11
+ #
12
+ # Typical usage in a controller:
13
+ #
14
+ # def show
15
+ # @room = Room.find(params[:id])
16
+ # Pgbus.stream(@room).presence.join(
17
+ # member_id: current_user.id.to_s,
18
+ # metadata: { name: current_user.name, avatar: current_user.avatar_url }
19
+ # )
20
+ # end
21
+ #
22
+ # And when the user leaves:
23
+ #
24
+ # Pgbus.stream(@room).presence.leave(member_id: current_user.id.to_s)
25
+ #
26
+ # Reading the current member list:
27
+ #
28
+ # Pgbus.stream(@room).presence.members
29
+ # # => [{ "id" => "7", "metadata" => {"name" => ..., "avatar" => ...},
30
+ # # "joined_at" => "...", "last_seen_at" => "..." }]
31
+ #
32
+ # The join/leave operations also fire a stream broadcast (via the
33
+ # caller-provided block) so connected clients see the change in real
34
+ # time. The library emits the broadcast through the regular pgbus
35
+ # stream pipeline, which means it's transactional, replayable, and
36
+ # filterable just like any other broadcast.
37
+ #
38
+ # NOT included in this v1:
39
+ # - Automatic connection-driven join/leave (the application must
40
+ # call join/leave explicitly).
41
+ # - Automatic stale-member sweeping (call Presence.sweep!(stream)
42
+ # from a cron or after a heartbeat to expire idle members).
43
+ # - Built-in DOM events on the <pgbus-stream-source> element
44
+ # (the application's broadcast block decides the HTML).
45
+ class Presence
46
+ def initialize(stream)
47
+ @stream = stream
48
+ end
49
+
50
+ # Adds (or refreshes) a member on this stream. Idempotent: calling
51
+ # join twice with the same member_id updates last_seen_at and
52
+ # metadata without creating a duplicate row. Yields the member
53
+ # hash to the optional block so the caller can render an
54
+ # `<turbo-stream action="append">` to broadcast the join.
55
+ def join(member_id:, metadata: {})
56
+ member_id = member_id.to_s
57
+ record = upsert_member(member_id, metadata)
58
+
59
+ if block_given?
60
+ html = yield(record)
61
+ @stream.broadcast(html) if html.is_a?(String) && !html.empty?
62
+ end
63
+
64
+ record
65
+ end
66
+
67
+ # Removes a member. Yields the (former) member hash to the
68
+ # optional block so the caller can broadcast a `remove` action.
69
+ # Returns the deleted record, or nil if the member wasn't present.
70
+ def leave(member_id:)
71
+ member_id = member_id.to_s
72
+ record = delete_member(member_id)
73
+ return nil unless record
74
+
75
+ if block_given?
76
+ html = yield(record)
77
+ @stream.broadcast(html) if html.is_a?(String) && !html.empty?
78
+ end
79
+
80
+ record
81
+ end
82
+
83
+ # Refreshes the last_seen_at timestamp without re-broadcasting.
84
+ # Used as a heartbeat mechanism: clients ping touch periodically
85
+ # to stay in the member list, and a sweeper expires anyone who
86
+ # hasn't pinged in N seconds.
87
+ def touch(member_id:)
88
+ member_id = member_id.to_s
89
+ connection.exec_params(<<~SQL, [@stream.name, member_id])
90
+ UPDATE pgbus_presence_members
91
+ SET last_seen_at = NOW()
92
+ WHERE stream_name = $1 AND member_id = $2
93
+ SQL
94
+ end
95
+
96
+ # Returns the current list of members on this stream as an
97
+ # array of hashes (id, metadata, joined_at, last_seen_at).
98
+ def members
99
+ rows = connection.exec_params(<<~SQL, [@stream.name])
100
+ SELECT member_id, metadata, joined_at, last_seen_at
101
+ FROM pgbus_presence_members
102
+ WHERE stream_name = $1
103
+ ORDER BY joined_at
104
+ SQL
105
+ rows.to_a.map do |row|
106
+ {
107
+ "id" => row["member_id"],
108
+ "metadata" => parse_metadata(row["metadata"]),
109
+ "joined_at" => row["joined_at"],
110
+ "last_seen_at" => row["last_seen_at"]
111
+ }
112
+ end
113
+ end
114
+
115
+ # Returns the count of current members. Faster than members.size
116
+ # because it doesn't deserialize metadata.
117
+ def count
118
+ rows = connection.exec_params(<<~SQL, [@stream.name])
119
+ SELECT COUNT(*) AS n FROM pgbus_presence_members WHERE stream_name = $1
120
+ SQL
121
+ rows.first["n"].to_i
122
+ end
123
+
124
+ # Removes members whose last_seen_at is older than the given cutoff.
125
+ # Returns the number of expired rows. Use as a sweeper:
126
+ #
127
+ # Pgbus.stream("room:42").presence.sweep!(older_than: 60.seconds.ago)
128
+ #
129
+ # Atomically claims the deletion via DELETE RETURNING so multiple
130
+ # workers running the sweep concurrently won't double-emit leave
131
+ # events.
132
+ def sweep!(older_than:)
133
+ rows = connection.exec_params(<<~SQL, [@stream.name, older_than])
134
+ DELETE FROM pgbus_presence_members
135
+ WHERE stream_name = $1 AND last_seen_at < $2
136
+ RETURNING member_id, metadata, joined_at, last_seen_at
137
+ SQL
138
+ rows.to_a.size
139
+ end
140
+
141
+ private
142
+
143
+ def upsert_member(member_id, metadata)
144
+ rows = connection.exec_params(<<~SQL, [@stream.name, member_id, JSON.generate(metadata)])
145
+ INSERT INTO pgbus_presence_members (stream_name, member_id, metadata, joined_at, last_seen_at)
146
+ VALUES ($1, $2, $3::jsonb, NOW(), NOW())
147
+ ON CONFLICT (stream_name, member_id)
148
+ DO UPDATE SET metadata = EXCLUDED.metadata, last_seen_at = NOW()
149
+ RETURNING member_id, metadata, joined_at, last_seen_at
150
+ SQL
151
+ row = rows.first
152
+ {
153
+ "id" => row["member_id"],
154
+ "metadata" => parse_metadata(row["metadata"]),
155
+ "joined_at" => row["joined_at"],
156
+ "last_seen_at" => row["last_seen_at"]
157
+ }
158
+ end
159
+
160
+ def delete_member(member_id)
161
+ rows = connection.exec_params(<<~SQL, [@stream.name, member_id])
162
+ DELETE FROM pgbus_presence_members
163
+ WHERE stream_name = $1 AND member_id = $2
164
+ RETURNING member_id, metadata, joined_at, last_seen_at
165
+ SQL
166
+ row = rows.first
167
+ return nil unless row
168
+
169
+ {
170
+ "id" => row["member_id"],
171
+ "metadata" => parse_metadata(row["metadata"]),
172
+ "joined_at" => row["joined_at"],
173
+ "last_seen_at" => row["last_seen_at"]
174
+ }
175
+ end
176
+
177
+ def parse_metadata(value)
178
+ return value if value.is_a?(Hash)
179
+ return {} if value.nil? || value.empty?
180
+
181
+ JSON.parse(value)
182
+ rescue JSON::ParserError => e
183
+ # Resilience fallback: return an empty hash rather than
184
+ # crashing the presence member list on one corrupt row. Debug
185
+ # log so operators can still find the corruption without
186
+ # impacting production throughput (debug is off by default).
187
+ # Truncate the value so a huge metadata payload doesn't bloat
188
+ # the log line.
189
+ truncated = value.to_s[0, 200]
190
+ Pgbus.logger&.debug do
191
+ "[Pgbus::Streams::Presence] parse_metadata failed (#{e.class}: #{e.message}); raw: #{truncated.inspect}"
192
+ end
193
+ {}
194
+ end
195
+
196
+ def connection
197
+ # Respect multi-database setups (connects_to). When
198
+ # Pgbus.configuration.connects_to is set, the pgbus tables
199
+ # live in a separate database accessed via Pgbus::BusRecord;
200
+ # the default path uses ActiveRecord::Base. Matches the
201
+ # canonical pattern in Pgbus::Process::QueueLock#connection
202
+ # and Pgbus::Configuration's connection probe.
203
+ unless defined?(::ActiveRecord::Base)
204
+ raise Pgbus::ConfigurationError,
205
+ "Pgbus::Streams::Presence requires ActiveRecord (no AR connection available)"
206
+ end
207
+
208
+ if Pgbus.configuration.connects_to
209
+ Pgbus::BusRecord.connection.raw_connection
210
+ else
211
+ ::ActiveRecord::Base.connection.raw_connection
212
+ end
213
+ end
214
+ end
215
+ end
216
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/message_verifier"
4
+
5
+ module Pgbus
6
+ module Streams
7
+ # Verifies tamper-proof stream identifiers carried in URLs (the
8
+ # `signed-stream-name` attribute set by `pgbus_stream_from`). Reuses
9
+ # `Turbo.signed_stream_verifier_key` when turbo-rails is loaded so that
10
+ # existing `broadcasts_to :room` calls Just Work; falls back to
11
+ # `Pgbus.configuration.streams_signed_name_secret` otherwise.
12
+ #
13
+ # The signed payload is the logical stream name as a string (e.g.
14
+ # `"gid://app/Order/42:messages"`). Verification returns that string;
15
+ # tampered or unsigned input raises `InvalidSignedName`.
16
+ module SignedName
17
+ class InvalidSignedName < StandardError
18
+ end
19
+
20
+ class MissingSecret < StandardError
21
+ end
22
+
23
+ def self.verify!(token)
24
+ raise InvalidSignedName, "signed stream name is blank" if token.nil? || token.to_s.strip.empty?
25
+
26
+ verifier.verified(token) || raise(InvalidSignedName, "signed stream name failed verification")
27
+ rescue ActiveSupport::MessageVerifier::InvalidSignature => e
28
+ raise InvalidSignedName, "signed stream name failed verification: #{e.message}"
29
+ end
30
+
31
+ def self.sign(stream_name)
32
+ verifier.generate(stream_name)
33
+ end
34
+
35
+ # Returns an ActiveSupport::MessageVerifier configured with whichever
36
+ # secret is appropriate for this process. Memoization is intentionally
37
+ # NOT used because tests rotate keys and the cost of constructing a
38
+ # verifier is negligible compared to the SHA256 work it does.
39
+ def self.verifier
40
+ ActiveSupport::MessageVerifier.new(secret, digest: "SHA256", serializer: JSON)
41
+ end
42
+
43
+ def self.secret
44
+ turbo_secret || configuration_secret || raise_missing_secret!
45
+ end
46
+
47
+ def self.turbo_secret
48
+ return nil unless defined?(::Turbo)
49
+
50
+ ::Turbo.signed_stream_verifier_key
51
+ rescue ArgumentError
52
+ # Real turbo-rails raises ArgumentError when the key is unset.
53
+ nil
54
+ end
55
+
56
+ def self.configuration_secret
57
+ Pgbus.configuration.streams_signed_name_secret
58
+ end
59
+
60
+ def self.raise_missing_secret!
61
+ raise MissingSecret,
62
+ "no signing secret available — set Pgbus.configuration.streams_signed_name_secret " \
63
+ "or Turbo.signed_stream_verifier_key"
64
+ end
65
+
66
+ private_class_method :verifier, :secret, :turbo_secret, :configuration_secret, :raise_missing_secret!
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module Streams
5
+ # Runtime patch that redirects `Turbo::StreamsChannel.broadcast_stream_to`
6
+ # through pgbus instead of `ActionCable.server.broadcast`. Applied at
7
+ # Rails engine boot time when `defined?(::Turbo::StreamsChannel)` —
8
+ # see `Pgbus::Engine`'s initializer. When turbo-rails isn't loaded,
9
+ # this patch is a no-op and pgbus streams continue to work via the
10
+ # explicit `Pgbus.stream(...).broadcast(...)` API.
11
+ #
12
+ # After the patch:
13
+ #
14
+ # class Order < ApplicationRecord
15
+ # broadcasts_to :account # existing turbo-rails API, unchanged
16
+ # end
17
+ #
18
+ # # In a controller:
19
+ # @order.update!(status: "shipped")
20
+ # # → Turbo::Broadcastable runs its after_update_commit callback
21
+ # # → calls Turbo::StreamsChannel.broadcast_replace_to
22
+ # # → which calls Turbo::StreamsChannel.broadcast_stream_to
23
+ # # → which is patched to call Pgbus.stream(name).broadcast(content)
24
+ # # → which inserts into PGMQ and fires NOTIFY
25
+ #
26
+ # Zero changes to user code. The entire Turbo::Broadcastable API
27
+ # (broadcasts_to, broadcasts_refreshes, broadcast_replace_to,
28
+ # broadcast_append_later_to, broadcasts_refreshes_to, etc) reuses
29
+ # this code path because they all funnel through broadcast_stream_to.
30
+ #
31
+ # Signed stream name reuse: we don't touch `Turbo.signed_stream_verifier`,
32
+ # so any existing `broadcasts_to :room` call continues to generate
33
+ # tokens that our `Pgbus::Streams::SignedName.verify!` accepts (as
34
+ # long as `Turbo.signed_stream_verifier_key` is set, which the Rails
35
+ # app is already responsible for).
36
+ module TurboBroadcastable
37
+ def broadcast_stream_to(*streamables, content:)
38
+ name = stream_name_from(streamables)
39
+ Pgbus.stream(name).broadcast(content)
40
+ end
41
+ end
42
+
43
+ # Apply the patch to Turbo::StreamsChannel's singleton class. Idempotent:
44
+ # prepending the same module twice is a no-op. Called from
45
+ # Pgbus::Engine's initializer when Turbo is detected.
46
+ def self.install_turbo_broadcastable_patch!
47
+ return unless defined?(::Turbo::StreamsChannel)
48
+ return if ::Turbo::StreamsChannel.singleton_class.include?(TurboBroadcastable)
49
+
50
+ ::Turbo::StreamsChannel.singleton_class.prepend(TurboBroadcastable)
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module Streams
5
+ # Rack middleware that clears the per-request thread-local watermark
6
+ # cache used by `Pgbus::StreamsHelper#pgbus_stream_from`. Without this
7
+ # middleware, a subsequent request served by the same thread would
8
+ # see stale `current_msg_id` values from the previous render — the
9
+ # pgbus_stream_from helper caches watermark lookups within a single
10
+ # request to avoid N+1 queries when a page uses multiple streams,
11
+ # and the cache would leak without a per-request boundary.
12
+ #
13
+ # Installed automatically by `Pgbus::Engine` at boot.
14
+ class WatermarkCacheMiddleware
15
+ CACHE_KEY = :pgbus_streams_watermark_cache
16
+
17
+ def initialize(app)
18
+ @app = app
19
+ end
20
+
21
+ def call(env)
22
+ @app.call(env)
23
+ ensure
24
+ Thread.current[CACHE_KEY] = nil
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ # Pgbus::Streams is the SSE-based pub/sub subsystem that replaces
5
+ # `turbo_stream_from`. The user-facing entrypoint is `Pgbus.stream(name)`,
6
+ # which returns a `Pgbus::Streams::Stream` providing `#broadcast`,
7
+ # `#current_msg_id`, and `#read_after`.
8
+ module Streams
9
+ # Process-wide registry of server-side audience filter predicates.
10
+ # Register filters at boot time via:
11
+ # Pgbus::Streams.filters.register(:admin_only) { |user| user.admin? }
12
+ # See lib/pgbus/streams/filters.rb for the full API.
13
+ def self.filters
14
+ @filters ||= Filters.new
15
+ end
16
+
17
+ # Clears the filters registry. Used by tests; not intended for runtime.
18
+ def self.reset_filters!
19
+ @filters = nil
20
+ end
21
+
22
+ # A handle on a single logical stream. The name can be any string, an
23
+ # object responding to `to_gid_param`, or an array of streamables (which
24
+ # are joined with colons — turbo-rails-compatible).
25
+ #
26
+ # Stream is *not* a singleton — callers create instances ad hoc, but the
27
+ # underlying queue is created lazily and only once per process per name.
28
+ class Stream
29
+ attr_reader :name
30
+
31
+ def initialize(streamables, client: Pgbus.client)
32
+ @name = self.class.name_from(streamables)
33
+ @client = client
34
+ @ensured = false
35
+ @ensure_mutex = Mutex.new
36
+ end
37
+
38
+ # Broadcasts a Turbo Stream HTML payload through the pgbus streamer.
39
+ # PGMQ's `message` column is JSONB, so raw HTML strings can't be passed
40
+ # directly. We wrap as `{"html": "..."}` on the way in and unwrap in
41
+ # Pgbus::Web::Streamer::Dispatcher before delivering to the SSE client.
42
+ # Callers pass a plain HTML string; the wrapping is an implementation
43
+ # detail.
44
+ #
45
+ # Transactional semantics: if this call is made inside an open
46
+ # ActiveRecord transaction, the PGMQ insert is deferred to an
47
+ # after_commit callback. If the transaction rolls back, the broadcast
48
+ # silently drops — clients never see the change that the database
49
+ # never persisted. This is the feature no other Rails real-time stack
50
+ # (including turbo-rails over ActionCable) can offer: the broadcast
51
+ # and the data mutation are atomic with respect to each other.
52
+ # Returns the assigned msg_id when sent synchronously, nil when
53
+ # deferred (the id isn't known until the after_commit callback runs).
54
+ #
55
+ # Audience filtering: pass `visible_to:` with a filter label (a
56
+ # Symbol previously registered via Pgbus::Streams.filters.register)
57
+ # to restrict delivery to connections whose authorize-hook context
58
+ # satisfies the predicate. The label travels with the broadcast
59
+ # through PGMQ; the predicate itself lives in-process on the
60
+ # subscriber side and is evaluated per-connection by the Dispatcher.
61
+ def broadcast(payload, visible_to: nil)
62
+ ensure_queue!
63
+ wrapped = { "html" => payload.to_s }
64
+ wrapped["visible_to"] = visible_to.to_s if visible_to
65
+ transaction = current_open_transaction
66
+ if transaction
67
+ transaction.after_commit { @client.send_message(@name, wrapped) }
68
+ nil
69
+ else
70
+ @client.send_message(@name, wrapped)
71
+ end
72
+ end
73
+
74
+ def current_msg_id
75
+ @client.stream_current_msg_id(@name)
76
+ end
77
+
78
+ def read_after(after_id:, limit: 500)
79
+ @client.read_after(@name, after_id: after_id, limit: limit)
80
+ end
81
+
82
+ def ensure!
83
+ ensure_queue!
84
+ self
85
+ end
86
+
87
+ # Returns a Pgbus::Streams::Presence handle for this stream.
88
+ # The Presence object exposes join/leave/touch/members/sweep!
89
+ # for tracking who is currently subscribed. See
90
+ # lib/pgbus/streams/presence.rb for the API.
91
+ def presence
92
+ @presence ||= Presence.new(self)
93
+ end
94
+
95
+ # Mirrors `Turbo::Streams::StreamName#stream_name_from`. Strings pass
96
+ # through; objects with `to_gid_param` or `to_param` are coerced; arrays
97
+ # are joined with `:`. The result is suitable both as a logical stream
98
+ # identifier and as the input to QueueNameValidator (after sanitisation).
99
+ def self.name_from(streamables)
100
+ if streamables.is_a?(Array)
101
+ streamables.map { |s| name_from(s) }.join(":")
102
+ elsif streamables.respond_to?(:to_gid_param)
103
+ streamables.to_gid_param
104
+ elsif streamables.respond_to?(:to_param) && !streamables.is_a?(Symbol)
105
+ streamables.to_param
106
+ else
107
+ streamables.to_s
108
+ end
109
+ end
110
+
111
+ private
112
+
113
+ def ensure_queue!
114
+ return if @ensured
115
+
116
+ @ensure_mutex.synchronize do
117
+ return if @ensured
118
+
119
+ @client.ensure_stream_queue(@name)
120
+ @ensured = true
121
+ end
122
+ end
123
+
124
+ # Returns the current AR transaction if one is open, nil otherwise.
125
+ # Guarded by defined? so the streams subsystem stays usable in
126
+ # non-Rails contexts. AR's NullTransaction yields immediately from
127
+ # after_commit, so we have to check #open? explicitly — otherwise
128
+ # every call path would hit the "deferred" branch and we'd lose the
129
+ # msg_id return value.
130
+ def current_open_transaction
131
+ return nil unless defined?(::ActiveRecord::Base)
132
+
133
+ connection = ::ActiveRecord::Base.connection
134
+ transaction = connection.current_transaction
135
+ transaction if transaction.open?
136
+ rescue StandardError => e
137
+ # Defensive: if AR is loaded but not yet connected (e.g. a
138
+ # Rake task invoked before Rails boot), don't let the transaction
139
+ # probe break the broadcast. Debug-logged so a misconfigured
140
+ # app can diagnose "why aren't my transactional broadcasts
141
+ # deferring?" without impacting production performance
142
+ # (debug is off by default).
143
+ Pgbus.logger.debug do
144
+ "[Pgbus::Streams::Stream] transaction probe failed (#{e.class}: #{e.message}); " \
145
+ "falling back to synchronous broadcast"
146
+ end
147
+ nil
148
+ end
149
+ end
150
+ end
151
+ end
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.5.1"
4
+ VERSION = "0.6.1"
5
5
  end
@@ -38,7 +38,10 @@ module Pgbus
38
38
  # different connection lifecycle than the worker processes).
39
39
  def queues_with_metrics
40
40
  queue_names = connection.select_values("SELECT queue_name FROM pgmq.meta ORDER BY queue_name")
41
- paused_queues = paused_queue_names
41
+ # paused_queue_names returns an Array; convert to Set so the
42
+ # per-queue membership check is O(1). With 100+ queues the
43
+ # Array#include? cost in the loop was O(n²) per dashboard load.
44
+ paused_queues = paused_queue_names.to_set
42
45
  queue_names.map { |name| queue_metrics_via_sql(name) }.compact.map do |q|
43
46
  q.merge(paused: paused_queues.include?(logical_queue_name(q[:name])))
44
47
  end
@@ -263,11 +266,7 @@ module Pgbus
263
266
  queues = queues_with_metrics.select { |q| q[:name].end_with?(dlq_suffix) }
264
267
  offset = (page - 1) * per_page
265
268
 
266
- messages = queues.flat_map do |q|
267
- query_queue_messages_raw(q[:name], per_page + offset, 0)
268
- end
269
-
270
- messages.sort_by { |m| -m[:msg_id].to_i }.slice(offset, per_page) || []
269
+ paginated_queue_messages(queues.map { |q| q[:name] }, per_page, offset)
271
270
  rescue StandardError => e
272
271
  Pgbus.logger.debug { "[Pgbus::Web] Error fetching DLQ messages: #{e.message}" }
273
272
  []
@@ -630,6 +629,35 @@ module Pgbus
630
629
  []
631
630
  end
632
631
 
632
+ # Stream stats — only populated when streams_stats_enabled is
633
+ # true AND the migration has been run. Controllers should gate
634
+ # rendering on `stream_stats_available?` to avoid showing empty
635
+ # sections.
636
+ def stream_stats_available?
637
+ Pgbus.configuration.streams_stats_enabled && StreamStat.table_exists?
638
+ rescue StandardError => e
639
+ Pgbus.logger.debug { "[Pgbus::Web] Error checking stream stats availability: #{e.message}" }
640
+ false
641
+ end
642
+
643
+ def stream_stats_summary(minutes: 60)
644
+ StreamStat.summary(minutes: minutes)
645
+ rescue StandardError => e
646
+ Pgbus.logger.debug { "[Pgbus::Web] Error fetching stream stats summary: #{e.message}" }
647
+ {
648
+ broadcasts: 0, connects: 0, disconnects: 0,
649
+ active_estimate: 0, avg_fanout: 0,
650
+ avg_broadcast_ms: 0, avg_connect_ms: 0
651
+ }
652
+ end
653
+
654
+ def top_streams(limit: 10, minutes: 60)
655
+ StreamStat.top_streams(limit: limit, minutes: minutes)
656
+ rescue StandardError => e
657
+ Pgbus.logger.debug { "[Pgbus::Web] Error fetching top streams: #{e.message}" }
658
+ []
659
+ end
660
+
633
661
  # Subscriber registry
634
662
  def registered_subscribers
635
663
  EventBus::Registry.instance.subscribers.map do |s|
@@ -666,10 +694,41 @@ module Pgbus
666
694
  def all_queue_messages(limit, offset)
667
695
  dlq_suffix = Pgbus::DEAD_LETTER_SUFFIX
668
696
  queues = queues_with_metrics.reject { |q| q[:name].end_with?(dlq_suffix) }
669
- messages = queues.flat_map do |q|
670
- query_queue_messages_raw(q[:name], limit + offset, 0)
697
+ paginated_queue_messages(queues.map { |q| q[:name] }, limit, offset)
698
+ end
699
+
700
+ # Returns messages from multiple PGMQ queues in a single paginated
701
+ # query. Builds a UNION ALL across the target tables and pushes the
702
+ # ORDER BY msg_id DESC + LIMIT + OFFSET down to Postgres so we don't
703
+ # load (limit + offset) rows from every queue into Ruby just to slice
704
+ # out one page. Returns [] if queue_names is empty.
705
+ #
706
+ # Each UNION ALL fragment selects a literal queue name so the outer
707
+ # query can tag every row with its source queue — the pre-SQL
708
+ # implementation tagged it from the iteration variable. The queue
709
+ # name goes through sanitize_name (which calls QueueNameValidator)
710
+ # so it's safe to interpolate into both the schema-qualified table
711
+ # and the literal column. limit/offset are bound parameters.
712
+ def paginated_queue_messages(queue_names, limit, offset)
713
+ return [] if queue_names.empty?
714
+
715
+ sanitized = queue_names.map { |name| [name, sanitize_name(name)] }
716
+ fragments = sanitized.map do |(name, qtable)|
717
+ <<~SQL.strip
718
+ SELECT msg_id, read_ct, enqueued_at, last_read_at, vt, message, headers,
719
+ '#{name}' AS queue_name
720
+ FROM pgmq.q_#{qtable}
721
+ SQL
671
722
  end
672
- messages.sort_by { |m| -m[:msg_id].to_i }.slice(offset, limit) || []
723
+
724
+ sql = <<~SQL
725
+ SELECT * FROM (#{fragments.join("\nUNION ALL\n")}) AS combined
726
+ ORDER BY msg_id DESC
727
+ LIMIT $1 OFFSET $2
728
+ SQL
729
+
730
+ rows = connection.select_all(sql, "Pgbus Paginated Queue Messages", [limit, offset])
731
+ rows.to_a.map { |r| format_message(r, r["queue_name"]) }
673
732
  end
674
733
 
675
734
  def queue_metrics_via_sql(queue_name)
@@ -874,12 +933,31 @@ module Pgbus
874
933
  end
875
934
 
876
935
  # Archive every queue message referenced by a failed_event row.
936
+ # Groups by queue and uses archive_batch so an N-event discard is
937
+ # one SQL statement per queue instead of N per-row roundtrips.
938
+ # Falls back to per-row archive inside a rescue if a batch fails,
939
+ # so one bad queue can't block progress on the others.
877
940
  def archive_all_failed_messages
878
941
  rows = connection.select_all(
879
942
  "SELECT id, queue_name, msg_id FROM pgbus_failed_events WHERE msg_id IS NOT NULL",
880
943
  "Pgbus Collect Failed Messages"
881
944
  )
882
- rows.to_a.each { |row| archive_failed_message(row) }
945
+
946
+ grouped = rows.to_a.group_by { |row| row["queue_name"] }
947
+ grouped.each do |queue_name, events|
948
+ msg_ids = events.filter_map { |row| row["msg_id"]&.to_i }
949
+ next if msg_ids.empty?
950
+
951
+ begin
952
+ @client.archive_batch(queue_name, msg_ids)
953
+ rescue StandardError => e
954
+ Pgbus.logger.debug do
955
+ "[Pgbus::Web] archive_batch failed for #{queue_name} (#{e.message}); " \
956
+ "falling back to per-row archive"
957
+ end
958
+ events.each { |row| archive_failed_message(row) }
959
+ end
960
+ end
883
961
  rescue StandardError => e
884
962
  Pgbus.logger.debug { "[Pgbus::Web] Error archiving failed messages: #{e.message}" }
885
963
  end