pgbus 0.5.0 → 0.6.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 +15 -0
- data/README.md +238 -0
- data/Rakefile +8 -1
- data/app/controllers/pgbus/insights_controller.rb +6 -0
- data/app/helpers/pgbus/streams_helper.rb +115 -0
- data/app/javascript/pgbus/stream_source_element.js +212 -0
- data/app/models/pgbus/stream_stat.rb +118 -0
- data/app/views/pgbus/insights/show.html.erb +59 -0
- data/config/locales/en.yml +16 -0
- data/config/routes.rb +11 -0
- data/lib/generators/pgbus/add_presence_generator.rb +55 -0
- data/lib/generators/pgbus/add_stream_stats_generator.rb +54 -0
- data/lib/generators/pgbus/templates/add_presence.rb.erb +26 -0
- data/lib/generators/pgbus/templates/add_stream_stats.rb.erb +18 -0
- data/lib/pgbus/client/ensure_stream_queue.rb +54 -0
- data/lib/pgbus/client/read_after.rb +100 -0
- data/lib/pgbus/client.rb +6 -0
- data/lib/pgbus/configuration/capsule_dsl.rb +6 -20
- data/lib/pgbus/configuration.rb +126 -14
- data/lib/pgbus/engine.rb +31 -0
- data/lib/pgbus/process/dispatcher.rb +62 -4
- data/lib/pgbus/streams/cursor.rb +71 -0
- data/lib/pgbus/streams/envelope.rb +58 -0
- data/lib/pgbus/streams/filters.rb +98 -0
- data/lib/pgbus/streams/presence.rb +216 -0
- data/lib/pgbus/streams/signed_name.rb +69 -0
- data/lib/pgbus/streams/turbo_broadcastable.rb +53 -0
- data/lib/pgbus/streams/watermark_cache_middleware.rb +28 -0
- data/lib/pgbus/streams.rb +151 -0
- data/lib/pgbus/version.rb +1 -1
- data/lib/pgbus/web/data_source.rb +29 -0
- data/lib/pgbus/web/stream_app.rb +179 -0
- data/lib/pgbus/web/streamer/connection.rb +122 -0
- data/lib/pgbus/web/streamer/dispatcher.rb +467 -0
- data/lib/pgbus/web/streamer/heartbeat.rb +105 -0
- data/lib/pgbus/web/streamer/instance.rb +176 -0
- data/lib/pgbus/web/streamer/io_writer.rb +73 -0
- data/lib/pgbus/web/streamer/listener.rb +228 -0
- data/lib/pgbus/web/streamer/registry.rb +103 -0
- data/lib/pgbus/web/streamer.rb +53 -0
- data/lib/pgbus.rb +28 -0
- data/lib/puma/plugin/pgbus_streams.rb +54 -0
- data/lib/tasks/pgbus_streams.rake +52 -0
- metadata +29 -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
|
@@ -630,6 +630,35 @@ module Pgbus
|
|
|
630
630
|
[]
|
|
631
631
|
end
|
|
632
632
|
|
|
633
|
+
# Stream stats — only populated when streams_stats_enabled is
|
|
634
|
+
# true AND the migration has been run. Controllers should gate
|
|
635
|
+
# rendering on `stream_stats_available?` to avoid showing empty
|
|
636
|
+
# sections.
|
|
637
|
+
def stream_stats_available?
|
|
638
|
+
Pgbus.configuration.streams_stats_enabled && StreamStat.table_exists?
|
|
639
|
+
rescue StandardError => e
|
|
640
|
+
Pgbus.logger.debug { "[Pgbus::Web] Error checking stream stats availability: #{e.message}" }
|
|
641
|
+
false
|
|
642
|
+
end
|
|
643
|
+
|
|
644
|
+
def stream_stats_summary(minutes: 60)
|
|
645
|
+
StreamStat.summary(minutes: minutes)
|
|
646
|
+
rescue StandardError => e
|
|
647
|
+
Pgbus.logger.debug { "[Pgbus::Web] Error fetching stream stats summary: #{e.message}" }
|
|
648
|
+
{
|
|
649
|
+
broadcasts: 0, connects: 0, disconnects: 0,
|
|
650
|
+
active_estimate: 0, avg_fanout: 0,
|
|
651
|
+
avg_broadcast_ms: 0, avg_connect_ms: 0
|
|
652
|
+
}
|
|
653
|
+
end
|
|
654
|
+
|
|
655
|
+
def top_streams(limit: 10, minutes: 60)
|
|
656
|
+
StreamStat.top_streams(limit: limit, minutes: minutes)
|
|
657
|
+
rescue StandardError => e
|
|
658
|
+
Pgbus.logger.debug { "[Pgbus::Web] Error fetching top streams: #{e.message}" }
|
|
659
|
+
[]
|
|
660
|
+
end
|
|
661
|
+
|
|
633
662
|
# Subscriber registry
|
|
634
663
|
def registered_subscribers
|
|
635
664
|
EventBus::Registry.instance.subscribers.map do |s|
|