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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +238 -0
  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 +118 -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_presence_generator.rb +55 -0
  12. data/lib/generators/pgbus/add_stream_stats_generator.rb +54 -0
  13. data/lib/generators/pgbus/templates/add_presence.rb.erb +26 -0
  14. data/lib/generators/pgbus/templates/add_stream_stats.rb.erb +18 -0
  15. data/lib/pgbus/client/ensure_stream_queue.rb +54 -0
  16. data/lib/pgbus/client/read_after.rb +100 -0
  17. data/lib/pgbus/client.rb +6 -0
  18. data/lib/pgbus/configuration.rb +65 -0
  19. data/lib/pgbus/engine.rb +31 -0
  20. data/lib/pgbus/process/dispatcher.rb +62 -4
  21. data/lib/pgbus/streams/cursor.rb +71 -0
  22. data/lib/pgbus/streams/envelope.rb +58 -0
  23. data/lib/pgbus/streams/filters.rb +98 -0
  24. data/lib/pgbus/streams/presence.rb +216 -0
  25. data/lib/pgbus/streams/signed_name.rb +69 -0
  26. data/lib/pgbus/streams/turbo_broadcastable.rb +53 -0
  27. data/lib/pgbus/streams/watermark_cache_middleware.rb +28 -0
  28. data/lib/pgbus/streams.rb +151 -0
  29. data/lib/pgbus/version.rb +1 -1
  30. data/lib/pgbus/web/data_source.rb +29 -0
  31. data/lib/pgbus/web/stream_app.rb +179 -0
  32. data/lib/pgbus/web/streamer/connection.rb +122 -0
  33. data/lib/pgbus/web/streamer/dispatcher.rb +467 -0
  34. data/lib/pgbus/web/streamer/heartbeat.rb +105 -0
  35. data/lib/pgbus/web/streamer/instance.rb +176 -0
  36. data/lib/pgbus/web/streamer/io_writer.rb +73 -0
  37. data/lib/pgbus/web/streamer/listener.rb +228 -0
  38. data/lib/pgbus/web/streamer/registry.rb +103 -0
  39. data/lib/pgbus/web/streamer.rb +53 -0
  40. data/lib/pgbus.rb +28 -0
  41. data/lib/puma/plugin/pgbus_streams.rb +54 -0
  42. data/lib/tasks/pgbus_streams.rake +52 -0
  43. 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
@@ -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.0"
5
5
  end
@@ -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