pgbus 0.5.1 → 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/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.rb +65 -0
- 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,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|
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rack/request"
|
|
4
|
+
|
|
5
|
+
module Pgbus
|
|
6
|
+
module Web
|
|
7
|
+
# Rack app mounted at /pgbus/streams. Not a Rails controller — this
|
|
8
|
+
# bypasses the entire Rails middleware stack for the streaming path,
|
|
9
|
+
# which avoids several classes of bug (ActionDispatch::Cookies leaking
|
|
10
|
+
# into long-lived requests, ActiveRecord::QueryCache holding the AR
|
|
11
|
+
# connection open, flash modifications after hijack, etc.). It also
|
|
12
|
+
# removes the temptation to use ActionController::Live, which has a
|
|
13
|
+
# well-documented tendency to tie up Puma threads (see puma#1009,
|
|
14
|
+
# puma#938, puma#569).
|
|
15
|
+
#
|
|
16
|
+
# Call flow (happy path, Puma 6.1+ hijack):
|
|
17
|
+
# 1. Parse the signed name from PATH_INFO
|
|
18
|
+
# 2. Verify it via Pgbus::Streams::SignedName (raises → 404)
|
|
19
|
+
# 3. Run the authorize hook (raises → 403)
|
|
20
|
+
# 4. Parse the cursor from ?since= or Last-Event-ID
|
|
21
|
+
# 5. Check streams_max_connections per worker (over → 503)
|
|
22
|
+
# 6. Check rack.hijack? (missing → 501)
|
|
23
|
+
# 7. Hijack, write the HTTP response line + SSE headers + opening
|
|
24
|
+
# comment directly to the socket
|
|
25
|
+
# 8. Build a Connection, hand to Streamer.current.register(...)
|
|
26
|
+
# 9. Return [-1, {}, []] (Puma's async protocol)
|
|
27
|
+
#
|
|
28
|
+
# On errors, returns a normal [status, headers, body] response so
|
|
29
|
+
# Rack / the reverse proxy can log and retry. The whole thing is
|
|
30
|
+
# designed so a reviewer can read call/2 top-to-bottom and see the
|
|
31
|
+
# full request lifecycle.
|
|
32
|
+
class StreamApp
|
|
33
|
+
PATH_PREFIX = "/pgbus/streams"
|
|
34
|
+
private_constant :PATH_PREFIX
|
|
35
|
+
|
|
36
|
+
def initialize(streamer: nil, config: nil, logger: nil, authorize: nil)
|
|
37
|
+
@streamer_override = streamer
|
|
38
|
+
@config_override = config
|
|
39
|
+
@logger_override = logger
|
|
40
|
+
@authorize = authorize || ->(_env, _stream_name) { true }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def call(env)
|
|
44
|
+
request = Rack::Request.new(env)
|
|
45
|
+
return not_found("only GET is supported") unless request.get?
|
|
46
|
+
|
|
47
|
+
signed_name = extract_signed_name(env)
|
|
48
|
+
return not_found("missing signed stream name") if signed_name.nil?
|
|
49
|
+
|
|
50
|
+
stream_name = verify!(signed_name)
|
|
51
|
+
return not_found("invalid signed stream name") if stream_name.nil?
|
|
52
|
+
|
|
53
|
+
authorize_result = @authorize.call(env, stream_name)
|
|
54
|
+
return forbidden unless authorize_result
|
|
55
|
+
|
|
56
|
+
# If authorize returned a non-boolean value (e.g. a User model),
|
|
57
|
+
# treat it as the connection context for audience filtering.
|
|
58
|
+
# A bare `true` means "authorized but no context" — filters that
|
|
59
|
+
# depend on a context will fail-closed on these connections.
|
|
60
|
+
context = authorize_result unless authorize_result == true
|
|
61
|
+
|
|
62
|
+
cursor = parse_cursor(env, request)
|
|
63
|
+
return bad_request("invalid cursor: #{cursor}") if cursor.is_a?(String)
|
|
64
|
+
|
|
65
|
+
return unsupported_server unless env["rack.hijack?"]
|
|
66
|
+
|
|
67
|
+
return over_capacity if streamer.registry.size >= config.streams_max_connections
|
|
68
|
+
|
|
69
|
+
hijack_and_register(env, stream_name: stream_name, since_id: cursor, context: context)
|
|
70
|
+
rescue StandardError => e
|
|
71
|
+
logger.error { "[Pgbus::StreamApp] #{e.class}: #{e.message}" }
|
|
72
|
+
server_error
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def extract_signed_name(env)
|
|
78
|
+
path = env["PATH_INFO"].to_s
|
|
79
|
+
# PATH_INFO when mounted is relative to the mount point, so it's
|
|
80
|
+
# just "/<signed_name>". When not mounted (e.g. tests calling call
|
|
81
|
+
# directly) it's "/pgbus/streams/<signed_name>". Handle both.
|
|
82
|
+
path = path.sub(PATH_PREFIX, "")
|
|
83
|
+
path = path[1..] if path.start_with?("/") # strip leading slash
|
|
84
|
+
return nil if path.nil? || path.empty?
|
|
85
|
+
|
|
86
|
+
path
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def verify!(token)
|
|
90
|
+
Pgbus::Streams::SignedName.verify!(token)
|
|
91
|
+
rescue Pgbus::Streams::SignedName::InvalidSignedName,
|
|
92
|
+
Pgbus::Streams::SignedName::MissingSecret
|
|
93
|
+
nil
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def parse_cursor(env, request)
|
|
97
|
+
Pgbus::Streams::Cursor.parse(
|
|
98
|
+
query_since: request.params["since"],
|
|
99
|
+
last_event_id: env["HTTP_LAST_EVENT_ID"]
|
|
100
|
+
)
|
|
101
|
+
rescue Pgbus::Streams::Cursor::InvalidCursor => e
|
|
102
|
+
e.message
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def hijack_and_register(env, stream_name:, since_id:, context: nil)
|
|
106
|
+
# Rack hijack is a one-time transition per the Rack spec. Call
|
|
107
|
+
# once, use the return value, and fall back to the side-effect
|
|
108
|
+
# `rack.hijack_io` variable only if the return was nil (some
|
|
109
|
+
# older Rack versions populate the side-effect var instead of
|
|
110
|
+
# returning the IO).
|
|
111
|
+
hijack_result = env["rack.hijack"].call
|
|
112
|
+
io = hijack_result || env["rack.hijack_io"]
|
|
113
|
+
|
|
114
|
+
write_headers(io, stream_name: stream_name, since_id: since_id)
|
|
115
|
+
|
|
116
|
+
connection = Pgbus::Web::Streamer::Connection.new(
|
|
117
|
+
id: SecureRandom.hex(8),
|
|
118
|
+
stream_name: stream_name,
|
|
119
|
+
io: io,
|
|
120
|
+
since_id: since_id,
|
|
121
|
+
writer: Pgbus::Web::Streamer::IoWriter,
|
|
122
|
+
write_deadline_ms: config.streams_write_deadline_ms,
|
|
123
|
+
context: context
|
|
124
|
+
)
|
|
125
|
+
streamer.register(connection)
|
|
126
|
+
|
|
127
|
+
[-1, {}, []]
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def write_headers(io, stream_name:, since_id:)
|
|
131
|
+
io.write(Pgbus::Streams::Envelope.http_response_headers)
|
|
132
|
+
io.write(Pgbus::Streams::Envelope.retry_directive(2_000))
|
|
133
|
+
io.write(Pgbus::Streams::Envelope.comment("pgbus stream open since_id=#{since_id} stream=#{stream_name}"))
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def streamer
|
|
137
|
+
@streamer_override || Pgbus::Web::Streamer.current
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def config
|
|
141
|
+
@config_override || Pgbus.configuration
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def logger
|
|
145
|
+
@logger_override || Pgbus.logger
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Error responses — plain text, no body parsing, no Rails. The client
|
|
149
|
+
# is EventSource which doesn't render error bodies, but we include
|
|
150
|
+
# short text for operator debugging.
|
|
151
|
+
|
|
152
|
+
def not_found(reason)
|
|
153
|
+
[404, { "content-type" => "text/plain" }, ["pgbus: #{reason}"]]
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def forbidden
|
|
157
|
+
[403, { "content-type" => "text/plain" }, ["pgbus: forbidden"]]
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def bad_request(reason)
|
|
161
|
+
[400, { "content-type" => "text/plain" }, ["pgbus: #{reason}"]]
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def over_capacity
|
|
165
|
+
[503, { "content-type" => "text/plain", "retry-after" => "2" }, ["pgbus: streamer over capacity"]]
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def unsupported_server
|
|
169
|
+
[501,
|
|
170
|
+
{ "content-type" => "text/plain" },
|
|
171
|
+
["pgbus streams require Puma 6.1+ or Falcon — current Rack server does not provide rack.hijack"]]
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def server_error
|
|
175
|
+
[500, { "content-type" => "text/plain" }, ["pgbus: internal error"]]
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
module Web
|
|
5
|
+
module Streamer
|
|
6
|
+
# Wraps a single hijacked SSE client socket with its own cursor state,
|
|
7
|
+
# per-io mutex, and liveness flag. Owns no threads — the Dispatcher and
|
|
8
|
+
# Heartbeat threads call #enqueue / #write_comment on Connection instances
|
|
9
|
+
# directly, and the per-io mutex in IoWriter serialises concurrent writes.
|
|
10
|
+
#
|
|
11
|
+
# Cursor semantics: `last_msg_id_sent` is strictly monotonic. `enqueue`
|
|
12
|
+
# filters envelopes with `msg_id > last_msg_id_sent` and advances the
|
|
13
|
+
# cursor only for envelopes that actually wrote successfully. This is
|
|
14
|
+
# the client-side leg of the replay-race fix (§6.5 of the design doc).
|
|
15
|
+
class Connection
|
|
16
|
+
attr_reader :id, :stream_name, :io, :mutex, :last_msg_id_sent, :context
|
|
17
|
+
|
|
18
|
+
def initialize(id:, stream_name:, io:, since_id:, writer:, write_deadline_ms:, context: nil)
|
|
19
|
+
@id = id
|
|
20
|
+
@stream_name = stream_name
|
|
21
|
+
@io = io
|
|
22
|
+
@last_msg_id_sent = since_id.to_i
|
|
23
|
+
@writer = writer
|
|
24
|
+
@write_deadline_ms = write_deadline_ms
|
|
25
|
+
@mutex = Mutex.new
|
|
26
|
+
@dead = false
|
|
27
|
+
@closed = false
|
|
28
|
+
@created_at = monotonic
|
|
29
|
+
@last_write_at = @created_at
|
|
30
|
+
# Context is whatever the StreamApp's authorize hook returned
|
|
31
|
+
# (a truthy non-boolean value). Typically a user model or a
|
|
32
|
+
# session hash. The Dispatcher passes it to the Filters
|
|
33
|
+
# registry when evaluating visible_to predicates. Defaults to
|
|
34
|
+
# nil for tests that don't need audience filtering.
|
|
35
|
+
@context = context
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def enqueue(envelopes)
|
|
39
|
+
written = []
|
|
40
|
+
envelopes.each do |envelope|
|
|
41
|
+
next if envelope.msg_id <= @last_msg_id_sent
|
|
42
|
+
|
|
43
|
+
bytes = Pgbus::Streams::Envelope.message(
|
|
44
|
+
id: envelope.msg_id,
|
|
45
|
+
event: "turbo-stream",
|
|
46
|
+
data: envelope.payload
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
result = @writer.write(self, bytes, deadline_ms: @write_deadline_ms)
|
|
50
|
+
if result == :ok
|
|
51
|
+
@last_msg_id_sent = envelope.msg_id
|
|
52
|
+
@last_write_at = monotonic
|
|
53
|
+
written << envelope
|
|
54
|
+
else
|
|
55
|
+
mark_dead!
|
|
56
|
+
break
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
written
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def write_comment(text)
|
|
63
|
+
bytes = Pgbus::Streams::Envelope.comment(text)
|
|
64
|
+
result = @writer.write(self, bytes, deadline_ms: @write_deadline_ms)
|
|
65
|
+
if result == :ok
|
|
66
|
+
@last_write_at = monotonic
|
|
67
|
+
else
|
|
68
|
+
mark_dead!
|
|
69
|
+
end
|
|
70
|
+
result
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def idle_for
|
|
74
|
+
monotonic - @last_write_at
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def dead?
|
|
78
|
+
@dead
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def mark_dead!
|
|
82
|
+
@dead = true
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Idempotent socket close for use by Instance#shutdown! and the
|
|
86
|
+
# heartbeat idle reaper. Wraps the respond_to? / closed? dance
|
|
87
|
+
# so callers don't need to know about StringIO-in-tests vs real
|
|
88
|
+
# Socket-in-prod or about the mark_dead! ordering.
|
|
89
|
+
#
|
|
90
|
+
# Takes the same mutex as IoWriter.write so it can't fire
|
|
91
|
+
# mid-write — otherwise the write loop could hit a half-closed
|
|
92
|
+
# socket and corrupt the `last_msg_id_sent` cursor by marking
|
|
93
|
+
# the connection dead between successful writes. The rescue
|
|
94
|
+
# narrows to IO-related exceptions; unrelated errors (bugs in
|
|
95
|
+
# the fake IO used by tests, nil-dereferences, etc.) should
|
|
96
|
+
# still propagate so the test suite catches them.
|
|
97
|
+
def close
|
|
98
|
+
@mutex.synchronize do
|
|
99
|
+
return if @closed
|
|
100
|
+
|
|
101
|
+
@closed = true
|
|
102
|
+
mark_dead!
|
|
103
|
+
return unless @io.respond_to?(:close)
|
|
104
|
+
|
|
105
|
+
@io.close unless @io.respond_to?(:closed?) && @io.closed?
|
|
106
|
+
end
|
|
107
|
+
rescue IOError, SystemCallError => e
|
|
108
|
+
Pgbus.logger&.debug { "[Pgbus::Streamer::Connection] close failed: #{e.class}: #{e.message}" }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
def monotonic
|
|
114
|
+
# Qualify ::Process because Pgbus::Process already exists as a
|
|
115
|
+
# namespace for worker/supervisor/consumer and would otherwise
|
|
116
|
+
# shadow the top-level constant here.
|
|
117
|
+
::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|