pgbus 0.9.0 → 0.9.2
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 +16 -0
- data/app/assets/javascripts/pgbus/stream_source_element.js +150 -5
- data/app/views/pgbus/batches/_batches_table.html.erb +2 -1
- data/lib/pgbus/configuration.rb +59 -0
- data/lib/pgbus/execution_pools/async_pool.rb +44 -6
- data/lib/pgbus/streams/coalescer.rb +88 -0
- data/lib/pgbus/streams/envelope.rb +20 -1
- data/lib/pgbus/streams/key.rb +35 -2
- data/lib/pgbus/streams/renderer.rb +67 -0
- data/lib/pgbus/streams.rb +150 -1
- data/lib/pgbus/version.rb +1 -1
- data/lib/pgbus/web/stream_app.rb +8 -4
- data/lib/pgbus/web/streamer/connection.rb +15 -1
- data/lib/pgbus/web/streamer/falcon_connection.rb +9 -1
- data/lib/pgbus/web/streamer/heartbeat.rb +23 -1
- data/lib/pgbus/web/streamer/stream_event_dispatcher.rb +129 -14
- data/lib/pgbus.rb +11 -0
- metadata +3 -1
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cgi"
|
|
4
|
+
|
|
5
|
+
module Pgbus
|
|
6
|
+
module Streams
|
|
7
|
+
# Turns a renderable into a complete `<turbo-stream>` action tag,
|
|
8
|
+
# ready to hand to Stream#broadcast. This centralises the off-request
|
|
9
|
+
# render + tag-building that every consumer would otherwise stitch
|
|
10
|
+
# together by hand (the #1 footgun in server-driven UI: rendering a
|
|
11
|
+
# component outside a request without a usable view context).
|
|
12
|
+
#
|
|
13
|
+
# pgbus deliberately has no hard dependency on turbo-rails, ActionView,
|
|
14
|
+
# or Phlex, so this builder is self-contained and matches Turbo's wire
|
|
15
|
+
# format directly. The browser's Turbo runtime consumes the tag; the
|
|
16
|
+
# exact string is the contract, not any particular Ruby library.
|
|
17
|
+
#
|
|
18
|
+
# Renderable resolution (first match wins):
|
|
19
|
+
# - String → used verbatim (already-rendered markup)
|
|
20
|
+
# - responds to :call → Phlex::HTML#call (the issue's example shape)
|
|
21
|
+
# - responds to :render_in → ViewComponent / phlex-rails
|
|
22
|
+
# (`render_in(view_context)`; a nil context is passed because
|
|
23
|
+
# off-request there is no controller — components that need URL
|
|
24
|
+
# helpers should be rendered by the app and the string passed in)
|
|
25
|
+
# - else → to_s
|
|
26
|
+
#
|
|
27
|
+
# Tag format mirrors Turbo::Streams::TagBuilder:
|
|
28
|
+
# - content actions wrap the markup in a <template>
|
|
29
|
+
# - content-less actions (remove) emit no <template>
|
|
30
|
+
module Renderer
|
|
31
|
+
# Turbo stream actions that carry no content (no <template> wrapper).
|
|
32
|
+
CONTENTLESS_ACTIONS = %w[remove].freeze
|
|
33
|
+
|
|
34
|
+
module_function
|
|
35
|
+
|
|
36
|
+
# Builds a `<turbo-stream action target><template>...</template></turbo-stream>`
|
|
37
|
+
# string. `renderable` may be nil for content-less actions.
|
|
38
|
+
def turbo_stream_tag(action:, target:, renderable: nil)
|
|
39
|
+
raise ArgumentError, "target is required" if target.nil? || target.to_s.empty?
|
|
40
|
+
|
|
41
|
+
action = action.to_s
|
|
42
|
+
attrs = %(action="#{escape(action)}" target="#{escape(target)}")
|
|
43
|
+
|
|
44
|
+
return "<turbo-stream #{attrs}></turbo-stream>" if CONTENTLESS_ACTIONS.include?(action)
|
|
45
|
+
|
|
46
|
+
content = render(renderable)
|
|
47
|
+
"<turbo-stream #{attrs}><template>#{content}</template></turbo-stream>"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Resolves a renderable to an HTML string. See module docs for the
|
|
51
|
+
# resolution order. Returns "" for nil (a content action with no
|
|
52
|
+
# renderable still emits an empty <template>).
|
|
53
|
+
def render(renderable)
|
|
54
|
+
return "" if renderable.nil?
|
|
55
|
+
return renderable if renderable.is_a?(String)
|
|
56
|
+
return renderable.call.to_s if renderable.respond_to?(:call)
|
|
57
|
+
return renderable.render_in(nil).to_s if renderable.respond_to?(:render_in)
|
|
58
|
+
|
|
59
|
+
renderable.to_s
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def escape(value)
|
|
63
|
+
CGI.escape_html(value.to_s)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
data/lib/pgbus/streams.rb
CHANGED
|
@@ -15,6 +15,12 @@ module Pgbus
|
|
|
15
15
|
# this specifically can rescue Pgbus::Streams::StreamNameTooLong.
|
|
16
16
|
class StreamNameTooLong < ArgumentError; end
|
|
17
17
|
|
|
18
|
+
# The default SSE `event:` name for a broadcast frame. Turbo's
|
|
19
|
+
# StreamObserver consumes frames the client re-dispatches as the
|
|
20
|
+
# `message` DOM event; the client maps this SSE event name to
|
|
21
|
+
# `message`. Broadcasts may override it with a typed name (issue #170).
|
|
22
|
+
DEFAULT_SSE_EVENT = "turbo-stream"
|
|
23
|
+
|
|
18
24
|
# Process-wide registry of server-side audience filter predicates.
|
|
19
25
|
# Register filters at boot time via:
|
|
20
26
|
# Pgbus::Streams.filters.register(:admin_only) { |user| user.admin? }
|
|
@@ -28,6 +34,25 @@ module Pgbus
|
|
|
28
34
|
@filters = nil
|
|
29
35
|
end
|
|
30
36
|
|
|
37
|
+
# Process-wide publish-side coalescer for high-frequency broadcasts
|
|
38
|
+
# (issue #171). Lazily built; the flush re-enters the normal broadcast
|
|
39
|
+
# path so a coalesced frame is just a deferred ordinary broadcast.
|
|
40
|
+
def self.coalescer
|
|
41
|
+
# `target:` is part of the flush signature but unused here — it's only
|
|
42
|
+
# a coalescing key; the target is already encoded in the payload's
|
|
43
|
+
# turbo-stream tag. Absorbed via ** so it isn't a broadcast argument.
|
|
44
|
+
@coalescer ||= Coalescer.new(
|
|
45
|
+
flush: lambda do |stream_name:, payload:, opts:, **|
|
|
46
|
+
Pgbus.stream(stream_name).broadcast(payload, **opts)
|
|
47
|
+
end
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Clears the coalescer. Used by tests; not intended for runtime.
|
|
52
|
+
def self.reset_coalescer!
|
|
53
|
+
@coalescer = nil
|
|
54
|
+
end
|
|
55
|
+
|
|
31
56
|
# A handle on a single logical stream. The name can be any string, an
|
|
32
57
|
# object responding to `to_gid_param`, or an array of streamables (which
|
|
33
58
|
# are joined with colons — turbo-rails-compatible).
|
|
@@ -76,9 +101,48 @@ module Pgbus
|
|
|
76
101
|
# Per-broadcast `durable:` overrides the stream-level default for a
|
|
77
102
|
# single broadcast. `nil` (the default) defers to the stream's own
|
|
78
103
|
# `durable?` setting; `true`/`false` flip the mode for this call only.
|
|
79
|
-
|
|
104
|
+
#
|
|
105
|
+
# Actor-echo suppression: pass `exclude:` with the broadcaster's own
|
|
106
|
+
# SSE connection id (surfaced to the page as
|
|
107
|
+
# `<meta name="pgbus-connection-id">` / the element's `connection-id`
|
|
108
|
+
# attribute, sent back on the action request as the
|
|
109
|
+
# `X-Pgbus-Connection` header). The dispatcher skips delivery to that
|
|
110
|
+
# one connection, so the actor doesn't receive the echo of its own
|
|
111
|
+
# broadcast — it already applied the change via the action's HTTP
|
|
112
|
+
# response. Everyone else gets the broadcast. A nil/blank `exclude`
|
|
113
|
+
# is a no-op (the common path).
|
|
114
|
+
# Typed SSE event name: pass `event:` to set the SSE `event:` field
|
|
115
|
+
# on the delivered frame (e.g. `event: "presence"`, `event:
|
|
116
|
+
# "reactive"`) while keeping the payload a Turbo Stream. Clients that
|
|
117
|
+
# care can route on the typed event without sniffing the HTML;
|
|
118
|
+
# default consumers still receive the standard turbo-stream/`message`
|
|
119
|
+
# path. The default (`nil` or `"turbo-stream"`) is not carried on the
|
|
120
|
+
# wire — it's the implicit default the dispatcher applies.
|
|
121
|
+
# High-frequency coalescing: pass `coalesce:` (a window in milliseconds,
|
|
122
|
+
# or `true` for the default window) together with `target:` to batch
|
|
123
|
+
# rapid broadcasts to the same (stream, target) and publish only the
|
|
124
|
+
# latest within the window. Superseded frames never reach the bus.
|
|
125
|
+
# Last-write-wins, so this is only safe for idempotent replace/update
|
|
126
|
+
# of a stable target (the high-frequency case: cursors, typing,
|
|
127
|
+
# progress). Returns nil — the actual broadcast is deferred to the
|
|
128
|
+
# coalescer's flush. See issue #171 and Pgbus::Streams::Coalescer.
|
|
129
|
+
def broadcast(payload, visible_to: nil, durable: nil, exclude: nil, event: nil, coalesce: nil, target: nil)
|
|
130
|
+
if coalesce
|
|
131
|
+
return coalesce_broadcast(
|
|
132
|
+
payload,
|
|
133
|
+
coalesce: coalesce,
|
|
134
|
+
target: target,
|
|
135
|
+
visible_to: visible_to,
|
|
136
|
+
durable: durable,
|
|
137
|
+
exclude: exclude,
|
|
138
|
+
event: event
|
|
139
|
+
)
|
|
140
|
+
end
|
|
141
|
+
|
|
80
142
|
wrapped = { "html" => payload.to_s }
|
|
81
143
|
wrapped["visible_to"] = visible_to.to_s if visible_to
|
|
144
|
+
wrapped["exclude"] = exclude.to_s if exclude && !exclude.to_s.empty?
|
|
145
|
+
wrapped["event"] = event.to_s if event && event.to_s != DEFAULT_SSE_EVENT
|
|
82
146
|
|
|
83
147
|
use_durable = durable.nil? ? @durable : durable
|
|
84
148
|
return broadcast_ephemeral(wrapped) unless use_durable
|
|
@@ -101,6 +165,36 @@ module Pgbus
|
|
|
101
165
|
end
|
|
102
166
|
end
|
|
103
167
|
|
|
168
|
+
# Renders a renderable (a Phlex component, a ViewComponent, or a
|
|
169
|
+
# pre-rendered HTML string) into a complete `<turbo-stream>` action
|
|
170
|
+
# tag and broadcasts it atomically — the render and the broadcast in
|
|
171
|
+
# one call. This removes the #1 footgun in server-driven UI: building
|
|
172
|
+
# the off-request render context and the turbo-stream wrapper by hand
|
|
173
|
+
# at every call site.
|
|
174
|
+
#
|
|
175
|
+
# Pgbus.stream("chat", room).broadcast_render(
|
|
176
|
+
# renderable: Chat::Message.new(chat_message: msg),
|
|
177
|
+
# action: :append, target: "chat-messages-#{room}",
|
|
178
|
+
# exclude: connection_id # composes with #165
|
|
179
|
+
# )
|
|
180
|
+
#
|
|
181
|
+
# `action` defaults to :replace. `target` is required. `exclude:`,
|
|
182
|
+
# `visible_to:`, and `durable:` are forwarded to #broadcast unchanged,
|
|
183
|
+
# so actor-echo suppression and audience filtering compose. Returns
|
|
184
|
+
# whatever #broadcast returns (msg_id, or nil when deferred to
|
|
185
|
+
# after_commit inside a transaction).
|
|
186
|
+
#
|
|
187
|
+
# See Pgbus::Streams::Renderer for the renderable-resolution order.
|
|
188
|
+
# Components that need URL helpers or a full view context should be
|
|
189
|
+
# rendered by the app (which has the request context) and the
|
|
190
|
+
# resulting string passed as `renderable:`.
|
|
191
|
+
def broadcast_render(target:, action: :replace, renderable: nil, visible_to: nil, durable: nil, exclude: nil,
|
|
192
|
+
event: nil, coalesce: nil)
|
|
193
|
+
html = Renderer.turbo_stream_tag(action: action, target: target, renderable: renderable)
|
|
194
|
+
broadcast(html, visible_to: visible_to, durable: durable, exclude: exclude, event: event,
|
|
195
|
+
coalesce: coalesce, target: target)
|
|
196
|
+
end
|
|
197
|
+
|
|
104
198
|
def current_msg_id
|
|
105
199
|
@client.stream_current_msg_id(@name)
|
|
106
200
|
end
|
|
@@ -172,6 +266,61 @@ module Pgbus
|
|
|
172
266
|
nil
|
|
173
267
|
end
|
|
174
268
|
|
|
269
|
+
# Submits a frame to the process-wide coalescer instead of
|
|
270
|
+
# broadcasting now. Requires a target (the dedupe key — there's no
|
|
271
|
+
# way to last-write-win without one). The window is `coalesce` in ms
|
|
272
|
+
# when numeric, else the coalescer's default. Returns nil; the
|
|
273
|
+
# coalescer flushes the latest frame as an ordinary broadcast.
|
|
274
|
+
def coalesce_broadcast(payload, coalesce:, target:, visible_to:, durable:, exclude:, event:)
|
|
275
|
+
if target.nil? || target.to_s.empty?
|
|
276
|
+
raise ArgumentError,
|
|
277
|
+
"broadcast(coalesce:) requires target: — coalescing keys on (stream, target), " \
|
|
278
|
+
"so the latest frame per target can win. Use broadcast_render(coalesce:) which " \
|
|
279
|
+
"already has a target, or pass target: explicitly."
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
window_ms = coalesce_window_ms(coalesce)
|
|
283
|
+
# Resolve durability against THIS stream instance now, not nil. The
|
|
284
|
+
# coalescer's flush re-enters Pgbus.stream(@name), whose durability
|
|
285
|
+
# could resolve differently (config patterns) than this instance —
|
|
286
|
+
# passing the resolved value keeps the coalesced frame's mode stable.
|
|
287
|
+
resolved_durable = durable.nil? ? @durable : durable
|
|
288
|
+
|
|
289
|
+
submit = lambda do
|
|
290
|
+
Streams.coalescer.submit(
|
|
291
|
+
stream_name: @name,
|
|
292
|
+
target: target.to_s,
|
|
293
|
+
payload: payload.to_s,
|
|
294
|
+
window_ms: window_ms,
|
|
295
|
+
opts: { visible_to: visible_to, durable: resolved_durable, exclude: exclude, event: event }
|
|
296
|
+
)
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# Transaction gating: a coalesced DURABLE broadcast made inside an
|
|
300
|
+
# open transaction must not flush if the transaction rolls back, so
|
|
301
|
+
# defer the submission to after_commit (mirrors the non-coalesced
|
|
302
|
+
# path). Ephemeral coalescing is fire-and-forget, so it submits now.
|
|
303
|
+
transaction = resolved_durable ? current_open_transaction : nil
|
|
304
|
+
if transaction
|
|
305
|
+
transaction.after_commit { submit.call }
|
|
306
|
+
else
|
|
307
|
+
submit.call
|
|
308
|
+
end
|
|
309
|
+
nil
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# Resolves the coalescing window in ms. `true` → the default window;
|
|
313
|
+
# a positive Numeric is used as-is. Anything else is a misuse and is
|
|
314
|
+
# rejected at the API boundary with an actionable error rather than a
|
|
315
|
+
# cryptic NoMethodError deep inside the coalescer's timer math.
|
|
316
|
+
def coalesce_window_ms(coalesce)
|
|
317
|
+
return Coalescer::DEFAULT_WINDOW_MS if coalesce == true
|
|
318
|
+
return coalesce if coalesce.is_a?(Numeric) && coalesce.positive?
|
|
319
|
+
|
|
320
|
+
raise ArgumentError,
|
|
321
|
+
"coalesce: must be true or a positive Numeric window in milliseconds, got #{coalesce.inspect}"
|
|
322
|
+
end
|
|
323
|
+
|
|
175
324
|
def ensure_queue!
|
|
176
325
|
return if @ensured
|
|
177
326
|
|
data/lib/pgbus/version.rb
CHANGED
data/lib/pgbus/web/stream_app.rb
CHANGED
|
@@ -117,10 +117,11 @@ module Pgbus
|
|
|
117
117
|
hijack_result = env["rack.hijack"].call
|
|
118
118
|
io = hijack_result || env["rack.hijack_io"]
|
|
119
119
|
|
|
120
|
-
|
|
120
|
+
connection_id = SecureRandom.hex(8)
|
|
121
|
+
write_headers(io, stream_name: stream_name, since_id: since_id, connection_id: connection_id)
|
|
121
122
|
|
|
122
123
|
connection = Pgbus::Web::Streamer::Connection.new(
|
|
123
|
-
id:
|
|
124
|
+
id: connection_id,
|
|
124
125
|
stream_name: stream_name,
|
|
125
126
|
io: io,
|
|
126
127
|
since_id: since_id,
|
|
@@ -138,14 +139,16 @@ module Pgbus
|
|
|
138
139
|
require_relative "streamer/falcon_connection" unless defined?(Pgbus::Web::Streamer::FalconConnection)
|
|
139
140
|
|
|
140
141
|
body = ::Protocol::HTTP::Body::Writable.new
|
|
142
|
+
connection_id = SecureRandom.hex(8)
|
|
141
143
|
|
|
142
144
|
body.write(Pgbus::Streams::Envelope.retry_directive(2_000))
|
|
143
145
|
body.write(Pgbus::Streams::Envelope.comment(
|
|
144
146
|
"pgbus stream open since_id=#{since_id} stream=#{stream_name}"
|
|
145
147
|
))
|
|
148
|
+
body.write(Pgbus::Streams::Envelope.connected(id: connection_id))
|
|
146
149
|
|
|
147
150
|
connection = Pgbus::Web::Streamer::FalconConnection.new(
|
|
148
|
-
id:
|
|
151
|
+
id: connection_id,
|
|
149
152
|
stream_name: stream_name,
|
|
150
153
|
body: body,
|
|
151
154
|
since_id: since_id,
|
|
@@ -165,10 +168,11 @@ module Pgbus
|
|
|
165
168
|
}
|
|
166
169
|
end
|
|
167
170
|
|
|
168
|
-
def write_headers(io, stream_name:, since_id:)
|
|
171
|
+
def write_headers(io, stream_name:, since_id:, connection_id:)
|
|
169
172
|
io.write(Pgbus::Streams::Envelope.http_response_headers)
|
|
170
173
|
io.write(Pgbus::Streams::Envelope.retry_directive(2_000))
|
|
171
174
|
io.write(Pgbus::Streams::Envelope.comment("pgbus stream open since_id=#{since_id} stream=#{stream_name}"))
|
|
175
|
+
io.write(Pgbus::Streams::Envelope.connected(id: connection_id))
|
|
172
176
|
end
|
|
173
177
|
|
|
174
178
|
def streamer
|
|
@@ -14,6 +14,10 @@ module Pgbus
|
|
|
14
14
|
# the client-side leg of the replay-race fix (§6.5 of the design doc).
|
|
15
15
|
class Connection
|
|
16
16
|
attr_reader :id, :stream_name, :io, :mutex, :last_msg_id_sent, :context
|
|
17
|
+
# The presence member id this connection auto-joined as, or nil for
|
|
18
|
+
# non-presence streams / anonymous connections. Set by the
|
|
19
|
+
# Dispatcher on connect; read on disconnect and heartbeat touch.
|
|
20
|
+
attr_accessor :presence_member
|
|
17
21
|
|
|
18
22
|
def initialize(id:, stream_name:, io:, since_id:, writer:, write_deadline_ms:, context: nil)
|
|
19
23
|
@id = id
|
|
@@ -25,6 +29,7 @@ module Pgbus
|
|
|
25
29
|
@mutex = Mutex.new
|
|
26
30
|
@dead = false
|
|
27
31
|
@closed = false
|
|
32
|
+
@presence_member = nil
|
|
28
33
|
@created_at = monotonic
|
|
29
34
|
@last_write_at = @created_at
|
|
30
35
|
# Context is whatever the StreamApp's authorize hook returned
|
|
@@ -43,7 +48,7 @@ module Pgbus
|
|
|
43
48
|
|
|
44
49
|
bytes = Pgbus::Streams::Envelope.message(
|
|
45
50
|
id: envelope.msg_id,
|
|
46
|
-
event:
|
|
51
|
+
event: sse_event_for(envelope),
|
|
47
52
|
data: envelope.payload
|
|
48
53
|
)
|
|
49
54
|
|
|
@@ -115,6 +120,15 @@ module Pgbus
|
|
|
115
120
|
|
|
116
121
|
private
|
|
117
122
|
|
|
123
|
+
# The SSE event name for a frame: the envelope's typed event when
|
|
124
|
+
# present, else the default turbo-stream. Plain ReadAfter::Envelopes
|
|
125
|
+
# (no event field) and StreamEnvelopes with a nil event both fall
|
|
126
|
+
# back to the default. (issue #170)
|
|
127
|
+
def sse_event_for(envelope)
|
|
128
|
+
event = envelope.respond_to?(:event) ? envelope.event : nil
|
|
129
|
+
event && !event.to_s.empty? ? event.to_s : Pgbus::Streams::DEFAULT_SSE_EVENT
|
|
130
|
+
end
|
|
131
|
+
|
|
118
132
|
def monotonic
|
|
119
133
|
# Qualify ::Process because Pgbus::Process already exists as a
|
|
120
134
|
# namespace for worker/supervisor/consumer and would otherwise
|
|
@@ -13,6 +13,7 @@ module Pgbus
|
|
|
13
13
|
# is fiber-safe under Falcon's scheduler.
|
|
14
14
|
class FalconConnection
|
|
15
15
|
attr_reader :id, :stream_name, :io, :mutex, :last_msg_id_sent, :context
|
|
16
|
+
attr_accessor :presence_member
|
|
16
17
|
|
|
17
18
|
def initialize(id:, stream_name:, body:, since_id:, write_deadline_ms:, context: nil)
|
|
18
19
|
@id = id
|
|
@@ -24,6 +25,7 @@ module Pgbus
|
|
|
24
25
|
@mutex = Mutex.new
|
|
25
26
|
@dead = false
|
|
26
27
|
@closed = false
|
|
28
|
+
@presence_member = nil
|
|
27
29
|
@created_at = monotonic
|
|
28
30
|
@last_write_at = @created_at
|
|
29
31
|
@context = context
|
|
@@ -36,7 +38,7 @@ module Pgbus
|
|
|
36
38
|
|
|
37
39
|
bytes = Pgbus::Streams::Envelope.message(
|
|
38
40
|
id: envelope.msg_id,
|
|
39
|
-
event:
|
|
41
|
+
event: sse_event_for(envelope),
|
|
40
42
|
data: envelope.payload
|
|
41
43
|
)
|
|
42
44
|
|
|
@@ -94,6 +96,12 @@ module Pgbus
|
|
|
94
96
|
|
|
95
97
|
private
|
|
96
98
|
|
|
99
|
+
# See Connection#sse_event_for. (issue #170)
|
|
100
|
+
def sse_event_for(envelope)
|
|
101
|
+
event = envelope.respond_to?(:event) ? envelope.event : nil
|
|
102
|
+
event && !event.to_s.empty? ? event.to_s : Pgbus::Streams::DEFAULT_SSE_EVENT
|
|
103
|
+
end
|
|
104
|
+
|
|
97
105
|
def write_to_body(bytes)
|
|
98
106
|
@mutex.synchronize do
|
|
99
107
|
return :closed if @dead || @body.closed?
|
|
@@ -61,6 +61,7 @@ module Pgbus
|
|
|
61
61
|
# code goes through the background thread.
|
|
62
62
|
def tick
|
|
63
63
|
now = @clock.call
|
|
64
|
+
presence_conns = []
|
|
64
65
|
@registry.each_connection do |connection|
|
|
65
66
|
if connection.dead?
|
|
66
67
|
# Already dead (e.g. IoWriter returned :closed on a previous
|
|
@@ -76,8 +77,15 @@ module Pgbus
|
|
|
76
77
|
end
|
|
77
78
|
|
|
78
79
|
result = connection.write_comment("heartbeat #{now.to_i}")
|
|
79
|
-
|
|
80
|
+
if connection.dead? || result != :ok
|
|
81
|
+
enqueue_disconnect(connection)
|
|
82
|
+
next
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
presence_conns << connection if presence_member?(connection)
|
|
80
86
|
end
|
|
87
|
+
|
|
88
|
+
enqueue_presence_touch(presence_conns)
|
|
81
89
|
end
|
|
82
90
|
|
|
83
91
|
private
|
|
@@ -99,6 +107,20 @@ module Pgbus
|
|
|
99
107
|
def enqueue_disconnect(connection)
|
|
100
108
|
@queue << StreamEventDispatcher::DisconnectMessage.new(connection: connection)
|
|
101
109
|
end
|
|
110
|
+
|
|
111
|
+
def presence_member?(connection)
|
|
112
|
+
connection.respond_to?(:presence_member) && !connection.presence_member.nil?
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Posts one batched touch per tick so the dispatcher (which owns AR
|
|
116
|
+
# connection release) refreshes last_seen_at for live presence
|
|
117
|
+
# members, keeping them out of the sweeper. No message when no
|
|
118
|
+
# connection has presence — avoids waking the dispatcher for nothing.
|
|
119
|
+
def enqueue_presence_touch(connections)
|
|
120
|
+
return if connections.empty?
|
|
121
|
+
|
|
122
|
+
@queue << StreamEventDispatcher::PresenceTouchMessage.new(connections: connections)
|
|
123
|
+
end
|
|
102
124
|
end
|
|
103
125
|
end
|
|
104
126
|
end
|
|
@@ -32,18 +32,34 @@ module Pgbus
|
|
|
32
32
|
# disambiguate from Pgbus::Process::Dispatcher, which is an
|
|
33
33
|
# unrelated worker-side pool coordinator. See issue #98 item 8.
|
|
34
34
|
class StreamEventDispatcher
|
|
35
|
-
WakeMessage
|
|
36
|
-
ConnectMessage
|
|
37
|
-
DisconnectMessage
|
|
35
|
+
WakeMessage = Listener::WakeMessage
|
|
36
|
+
ConnectMessage = Data.define(:connection)
|
|
37
|
+
DisconnectMessage = Data.define(:connection)
|
|
38
|
+
# Posted by the Heartbeat once per tick with the current presence
|
|
39
|
+
# connections, so the touch (a last_seen_at refresh) runs on the
|
|
40
|
+
# dispatcher thread where AR connections are released each pass.
|
|
41
|
+
PresenceTouchMessage = Data.define(:connections)
|
|
38
42
|
|
|
39
43
|
# An unwrapped stream broadcast. Similar shape to
|
|
40
44
|
# Pgbus::Client::ReadAfter::Envelope (msg_id + payload) so
|
|
41
45
|
# Connection#enqueue can consume either type via duck typing,
|
|
42
|
-
# but adds
|
|
43
|
-
# Pgbus::Streams::Stream#broadcast
|
|
44
|
-
# visible_to
|
|
45
|
-
#
|
|
46
|
-
|
|
46
|
+
# but adds two delivery-control fields carried through from
|
|
47
|
+
# Pgbus::Streams::Stream#broadcast:
|
|
48
|
+
# - `visible_to` — audience filter label (evaluated per-connection)
|
|
49
|
+
# - `exclude` — a connection id to skip (actor-echo suppression:
|
|
50
|
+
# the broadcaster's own SSE connection does not
|
|
51
|
+
# receive the echo of its own broadcast)
|
|
52
|
+
# The Dispatcher uses both to decide per-connection delivery;
|
|
53
|
+
# Connection never sees either field.
|
|
54
|
+
# - `event` — the SSE `event:` name for the delivered frame.
|
|
55
|
+
# nil means the default (turbo-stream); a typed
|
|
56
|
+
# name (e.g. "presence", "reactive") lets clients
|
|
57
|
+
# route without sniffing the HTML (issue #170).
|
|
58
|
+
StreamEnvelope = Data.define(:msg_id, :enqueued_at, :payload, :source, :visible_to, :exclude, :event) do
|
|
59
|
+
def initialize(msg_id:, enqueued_at:, payload:, source:, visible_to: nil, exclude: nil, event: nil)
|
|
60
|
+
super
|
|
61
|
+
end
|
|
62
|
+
end
|
|
47
63
|
|
|
48
64
|
DEFAULT_READ_LIMIT = 500
|
|
49
65
|
|
|
@@ -51,13 +67,18 @@ module Pgbus
|
|
|
51
67
|
|
|
52
68
|
def initialize(client:, registry:, listener:, dispatch_queue:,
|
|
53
69
|
logger: Pgbus.logger, read_limit: DEFAULT_READ_LIMIT,
|
|
54
|
-
filters: nil, config: nil, stream_counter: nil
|
|
70
|
+
filters: nil, config: nil, stream_counter: nil,
|
|
71
|
+
presence_provider: nil)
|
|
55
72
|
@client = client
|
|
56
73
|
@registry = registry
|
|
57
74
|
@listener = listener
|
|
58
75
|
@queue = dispatch_queue
|
|
59
76
|
@logger = logger
|
|
60
77
|
@read_limit = read_limit
|
|
78
|
+
# Vends a presence handle for a logical stream name. Injected so
|
|
79
|
+
# tests can record join/leave/touch without a DB. Production
|
|
80
|
+
# defaults to the real per-stream Presence via Pgbus.stream.
|
|
81
|
+
@presence_provider = presence_provider || ->(name) { Pgbus.stream(name).presence }
|
|
61
82
|
# Filters default to the process-wide registry so production
|
|
62
83
|
# code picks up whatever was registered at boot. Tests inject
|
|
63
84
|
# a fresh Filters instance to avoid cross-test pollution.
|
|
@@ -175,9 +196,10 @@ module Pgbus
|
|
|
175
196
|
|
|
176
197
|
def handle(msg)
|
|
177
198
|
case msg
|
|
178
|
-
when WakeMessage
|
|
179
|
-
when ConnectMessage
|
|
180
|
-
when DisconnectMessage
|
|
199
|
+
when WakeMessage then handle_wake(msg)
|
|
200
|
+
when ConnectMessage then handle_connect(msg)
|
|
201
|
+
when DisconnectMessage then handle_disconnect(msg)
|
|
202
|
+
when PresenceTouchMessage then handle_presence_touch(msg)
|
|
181
203
|
else
|
|
182
204
|
@logger.warn { "[Pgbus::Streamer::StreamEventDispatcher] unknown message: #{msg.class}" }
|
|
183
205
|
end
|
|
@@ -239,6 +261,7 @@ module Pgbus
|
|
|
239
261
|
|
|
240
262
|
visible_to = parsed["visible_to"]
|
|
241
263
|
visible_to = visible_to.to_sym if visible_to.is_a?(String)
|
|
264
|
+
exclude = parsed["exclude"]
|
|
242
265
|
|
|
243
266
|
@ephemeral_seq += 1
|
|
244
267
|
envelope = StreamEnvelope.new(
|
|
@@ -246,7 +269,9 @@ module Pgbus
|
|
|
246
269
|
enqueued_at: Time.now.utc.iso8601(6),
|
|
247
270
|
payload: html,
|
|
248
271
|
source: "ephemeral",
|
|
249
|
-
visible_to: visible_to
|
|
272
|
+
visible_to: visible_to,
|
|
273
|
+
exclude: exclude,
|
|
274
|
+
event: normalize_sse_event(parsed["event"])
|
|
250
275
|
)
|
|
251
276
|
|
|
252
277
|
registered.each do |conn|
|
|
@@ -320,6 +345,7 @@ module Pgbus
|
|
|
320
345
|
else
|
|
321
346
|
@stream_counter.increment_connections(stream)
|
|
322
347
|
@registry.register(connection)
|
|
348
|
+
presence_join(connection, stream)
|
|
323
349
|
end
|
|
324
350
|
|
|
325
351
|
record_stat(
|
|
@@ -346,6 +372,7 @@ module Pgbus
|
|
|
346
372
|
removed = @registry.unregister(connection)
|
|
347
373
|
@scanned_cursor.delete(connection)
|
|
348
374
|
@stream_counter.decrement_connections(stream) if removed
|
|
375
|
+
presence_leave(connection, stream)
|
|
349
376
|
cleanup_stream_if_unused(stream)
|
|
350
377
|
|
|
351
378
|
record_stat(
|
|
@@ -355,6 +382,21 @@ module Pgbus
|
|
|
355
382
|
)
|
|
356
383
|
end
|
|
357
384
|
|
|
385
|
+
# Touches (refreshes last_seen_at for) the presence members on the
|
|
386
|
+
# given connections. Posted by the Heartbeat each tick so idle but
|
|
387
|
+
# still-connected members don't get swept. Connections without a
|
|
388
|
+
# presence member (non-presence streams, anonymous) are skipped.
|
|
389
|
+
def handle_presence_touch(msg)
|
|
390
|
+
msg.connections.each do |connection|
|
|
391
|
+
member_id = presence_member_of(connection)
|
|
392
|
+
next unless member_id
|
|
393
|
+
|
|
394
|
+
@presence_provider.call(connection.stream_name).touch(member_id: member_id)
|
|
395
|
+
rescue StandardError => e
|
|
396
|
+
@logger.error { "[Pgbus::Streamer::StreamEventDispatcher] presence touch failed: #{e.class}: #{e.message}" }
|
|
397
|
+
end
|
|
398
|
+
end
|
|
399
|
+
|
|
358
400
|
# If this stream has no remaining subscribers (registered or
|
|
359
401
|
# in-flight), release all per-stream state so long-running
|
|
360
402
|
# processes don't leak memory proportional to unique stream
|
|
@@ -426,6 +468,22 @@ module Pgbus
|
|
|
426
468
|
@client.config.queue_name(stream_name)
|
|
427
469
|
end
|
|
428
470
|
|
|
471
|
+
# Sanitizes a typed SSE event name from an untrusted broadcast
|
|
472
|
+
# payload before it reaches the SSE `event:` line. Returns nil
|
|
473
|
+
# (→ the default turbo-stream event) for non-strings, blanks, or
|
|
474
|
+
# any value containing CR/LF — a crafted event with a newline could
|
|
475
|
+
# otherwise inject extra SSE fields (a forged id:/data:) into the
|
|
476
|
+
# frame and corrupt cursor/event routing. Defense in depth with
|
|
477
|
+
# Envelope.message, which also strips newlines.
|
|
478
|
+
def normalize_sse_event(value)
|
|
479
|
+
return nil unless value.is_a?(String)
|
|
480
|
+
|
|
481
|
+
event = value.strip
|
|
482
|
+
return nil if event.empty? || event.match?(/[\r\n]/)
|
|
483
|
+
|
|
484
|
+
event
|
|
485
|
+
end
|
|
486
|
+
|
|
429
487
|
# Pgbus::Streams::Stream#broadcast wraps HTML payloads as
|
|
430
488
|
# {"html": "..."} so PGMQ's JSONB column accepts them. Here we
|
|
431
489
|
# unwrap the html field and return a new envelope whose payload
|
|
@@ -442,13 +500,16 @@ module Pgbus
|
|
|
442
500
|
|
|
443
501
|
visible_to = parsed["visible_to"]
|
|
444
502
|
visible_to = visible_to.to_sym if visible_to.is_a?(String)
|
|
503
|
+
exclude = parsed["exclude"]
|
|
445
504
|
|
|
446
505
|
StreamEnvelope.new(
|
|
447
506
|
msg_id: envelope.msg_id,
|
|
448
507
|
enqueued_at: envelope.enqueued_at,
|
|
449
508
|
payload: html,
|
|
450
509
|
source: envelope.source,
|
|
451
|
-
visible_to: visible_to
|
|
510
|
+
visible_to: visible_to,
|
|
511
|
+
exclude: exclude,
|
|
512
|
+
event: normalize_sse_event(parsed["event"])
|
|
452
513
|
)
|
|
453
514
|
rescue JSON::ParserError
|
|
454
515
|
envelope
|
|
@@ -460,13 +521,67 @@ module Pgbus
|
|
|
460
521
|
# Filters registry. Envelopes that predate the StreamEnvelope
|
|
461
522
|
# refactor (plain ReadAfter::Envelope with no visible_to) also
|
|
462
523
|
# pass through.
|
|
524
|
+
#
|
|
525
|
+
# Actor-echo suppression: an envelope carrying `exclude:` (a
|
|
526
|
+
# connection id) is dropped for the connection whose id matches.
|
|
527
|
+
# This lets the broadcaster's own SSE connection skip the echo of
|
|
528
|
+
# its own broadcast — the actor already applied the change via its
|
|
529
|
+
# action's HTTP response, so re-applying the SSE echo would
|
|
530
|
+
# double-apply (re-run animations, clobber optimistic edits). The
|
|
531
|
+
# exclude check runs *before* the audience filter so an excluded
|
|
532
|
+
# actor is skipped even when it would otherwise match visible_to.
|
|
463
533
|
def visible_envelopes_for(envelopes, connection)
|
|
464
534
|
envelopes.select do |envelope|
|
|
535
|
+
next false if excluded?(envelope, connection)
|
|
536
|
+
|
|
465
537
|
label = envelope.respond_to?(:visible_to) ? envelope.visible_to : nil
|
|
466
538
|
@filters.visible?(label, connection.context)
|
|
467
539
|
end
|
|
468
540
|
end
|
|
469
541
|
|
|
542
|
+
# True when the envelope names this connection in its `exclude`
|
|
543
|
+
# field. Guarded by respond_to? so plain ReadAfter::Envelopes
|
|
544
|
+
# (no exclude field) and connections without an id never match.
|
|
545
|
+
def excluded?(envelope, connection)
|
|
546
|
+
return false unless envelope.respond_to?(:exclude)
|
|
547
|
+
|
|
548
|
+
exclude = envelope.exclude
|
|
549
|
+
return false if exclude.nil? || exclude.to_s.empty?
|
|
550
|
+
|
|
551
|
+
connection.respond_to?(:id) && connection.id.to_s == exclude.to_s
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
# Connection-driven presence (issue #169). Auto-joins a member when
|
|
555
|
+
# the stream is configured for presence and the connection's
|
|
556
|
+
# authorize-context yields a member id. Stores the member id on the
|
|
557
|
+
# connection so handle_disconnect/handle_presence_touch can act on
|
|
558
|
+
# it. Failures are logged and swallowed: a presence DB hiccup must
|
|
559
|
+
# not knock a live SSE connection out of the registry.
|
|
560
|
+
def presence_join(connection, stream)
|
|
561
|
+
return unless @config&.stream_presence?(stream)
|
|
562
|
+
|
|
563
|
+
member = @config.presence_member_for(connection.context)
|
|
564
|
+
return unless member
|
|
565
|
+
|
|
566
|
+
@presence_provider.call(stream).join(member_id: member[:id], metadata: member[:metadata] || {})
|
|
567
|
+
connection.presence_member = member[:id] if connection.respond_to?(:presence_member=)
|
|
568
|
+
rescue StandardError => e
|
|
569
|
+
@logger.error { "[Pgbus::Streamer::StreamEventDispatcher] presence join failed: #{e.class}: #{e.message}" }
|
|
570
|
+
end
|
|
571
|
+
|
|
572
|
+
def presence_leave(connection, stream)
|
|
573
|
+
member_id = presence_member_of(connection)
|
|
574
|
+
return unless member_id
|
|
575
|
+
|
|
576
|
+
@presence_provider.call(stream).leave(member_id: member_id)
|
|
577
|
+
rescue StandardError => e
|
|
578
|
+
@logger.error { "[Pgbus::Streamer::StreamEventDispatcher] presence leave failed: #{e.class}: #{e.message}" }
|
|
579
|
+
end
|
|
580
|
+
|
|
581
|
+
def presence_member_of(connection)
|
|
582
|
+
connection.respond_to?(:presence_member) ? connection.presence_member : nil
|
|
583
|
+
end
|
|
584
|
+
|
|
470
585
|
def monotonic_ms
|
|
471
586
|
::Process.clock_gettime(::Process::CLOCK_MONOTONIC) * 1000.0
|
|
472
587
|
end
|