pgbus 0.17.2 → 0.18.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 +10 -0
- data/README.md +55 -9
- data/Rakefile +26 -0
- data/app/assets/javascripts/pgbus/stream_source_element.js +37 -4
- data/app/assets/javascripts/pgbus/stream_source_element.min.js +6 -0
- data/app/assets/javascripts/pgbus/stream_source_element.min.js.map +1 -0
- data/app/controllers/pgbus/frontends_controller.rb +25 -11
- data/app/frontend/pgbus/application.min.js +2 -0
- data/app/frontend/pgbus/application.min.js.map +1 -0
- data/app/frontend/pgbus/modules/charts.js +24 -6
- data/app/frontend/pgbus/modules/charts.min.js +2 -0
- data/app/frontend/pgbus/modules/charts.min.js.map +1 -0
- data/app/frontend/pgbus/vendor/turbo.min.js +54 -0
- data/app/frontend/pgbus/vendor/turbo.min.js.map +1 -0
- data/app/views/layouts/pgbus/application.html.erb +0 -1
- data/config/routes.rb +1 -1
- data/lib/generators/pgbus/add_presence_generator.rb +2 -1
- data/lib/generators/pgbus/add_presence_seats_generator.rb +45 -0
- data/lib/generators/pgbus/templates/add_presence.rb.erb +32 -7
- data/lib/generators/pgbus/templates/add_presence_seats.rb.erb +38 -0
- data/lib/pgbus/configuration/presence_settings.rb +103 -0
- data/lib/pgbus/configuration/stream_authorization.rb +36 -0
- data/lib/pgbus/configuration.rb +16 -1
- data/lib/pgbus/engine.rb +13 -7
- data/lib/pgbus/generators/migration_detector.rb +9 -1
- data/lib/pgbus/streams/envelope.rb +3 -2
- data/lib/pgbus/streams/presence.rb +317 -81
- data/lib/pgbus/version.rb +1 -1
- data/lib/pgbus/web/stream_app.rb +43 -14
- data/lib/pgbus/web/streamer/connection.rb +26 -9
- data/lib/pgbus/web/streamer/falcon_connection.rb +23 -9
- data/lib/pgbus/web/streamer/heartbeat.rb +9 -3
- data/lib/pgbus/web/streamer/instance.rb +25 -8
- data/lib/pgbus/web/streamer/peer_close_watcher.rb +183 -0
- data/lib/pgbus/web/streamer/presence_tracker.rb +155 -0
- data/lib/pgbus/web/streamer/stream_event_dispatcher.rb +26 -39
- data/lib/pgbus.rb +2 -0
- metadata +15 -1
|
@@ -35,26 +35,230 @@ module Pgbus
|
|
|
35
35
|
# stream pipeline, which means it's transactional, replayable, and
|
|
36
36
|
# filterable just like any other broadcast.
|
|
37
37
|
#
|
|
38
|
-
#
|
|
39
|
-
#
|
|
40
|
-
#
|
|
41
|
-
#
|
|
42
|
-
#
|
|
38
|
+
# Seats (issue #515). A table migrated with `pgbus:add_presence_seats`
|
|
39
|
+
# also keeps one row per SSE connection: `connection_id` (part of the
|
|
40
|
+
# key), `seat` (the client's per-tab id, sent as `?seat=`) and a
|
|
41
|
+
# `state` jsonb for ephemeral per-tab state. Connection-driven presence
|
|
42
|
+
# (`streams_presence_patterns`) writes those rows, so two tabs of one
|
|
43
|
+
# user are two rows and the user stays present until both are gone.
|
|
44
|
+
# Member-keyed calls (no `connection_id:`) keep working on their own
|
|
45
|
+
# row (connection_id IS NULL), exactly as before.
|
|
46
|
+
#
|
|
47
|
+
# presence.join(member_id: "7", connection_id: conn_id, seat: seat)
|
|
48
|
+
# presence.set_state(connection_id: conn_id, state: { selected: 3 })
|
|
49
|
+
# presence.leave(connection_id: conn_id)
|
|
50
|
+
#
|
|
51
|
+
# Seat rows are expired by the streamer heartbeat after their stream's
|
|
52
|
+
# ttl (`Pgbus.configuration.presence_ttl_for`) and every join, leave
|
|
53
|
+
# and expiry is reported through `streams_presence_on_change`.
|
|
54
|
+
#
|
|
55
|
+
# Capability probe for a library building on seats:
|
|
56
|
+
# Pgbus::Streams::Presence.instance_method(:join).parameters
|
|
57
|
+
# .include?([:key, :connection_id]) # the gem has seats
|
|
58
|
+
# Pgbus::Streams::Presence.seats_supported? # the table is migrated
|
|
59
|
+
#
|
|
60
|
+
# NOT included:
|
|
43
61
|
# - Built-in DOM events on the <pgbus-stream-source> element
|
|
44
62
|
# (the application's broadcast block decides the HTML).
|
|
63
|
+
# - Expiry of member-keyed rows (call #sweep! from a cron for those).
|
|
45
64
|
class Presence
|
|
65
|
+
RECORD_COLUMNS = "member_id, metadata, joined_at, last_seen_at"
|
|
66
|
+
SEAT_RECORD_COLUMNS = "member_id, metadata, joined_at, last_seen_at, connection_id, seat, state"
|
|
67
|
+
|
|
68
|
+
# How long a "no seats" answer is trusted before the schema is probed
|
|
69
|
+
# again, so a table migrated under a running process is picked up.
|
|
70
|
+
SCHEMA_RECHECK_SECONDS = 30
|
|
71
|
+
|
|
72
|
+
@schema_mutex = Mutex.new
|
|
73
|
+
@seats_supported = nil
|
|
74
|
+
@probed_at = nil
|
|
75
|
+
|
|
76
|
+
class << self
|
|
77
|
+
# True when pgbus_presence_members has the seat columns (the
|
|
78
|
+
# add_presence_seats migration ran). A true answer is kept for the
|
|
79
|
+
# life of the process; a false one for SCHEMA_RECHECK_SECONDS.
|
|
80
|
+
def seats_supported?
|
|
81
|
+
return @seats_supported if schema_answer_fresh?
|
|
82
|
+
|
|
83
|
+
@schema_mutex.synchronize do
|
|
84
|
+
next @seats_supported if schema_answer_fresh?
|
|
85
|
+
|
|
86
|
+
rows = connection.exec_params(<<~SQL, [])
|
|
87
|
+
SELECT 1 AS present FROM information_schema.columns
|
|
88
|
+
WHERE table_name = 'pgbus_presence_members' AND column_name = 'connection_id'
|
|
89
|
+
AND table_schema = ANY (current_schemas(false))
|
|
90
|
+
SQL
|
|
91
|
+
@probed_at = monotonic_now
|
|
92
|
+
@seats_supported = !rows.to_a.empty?
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# seats_supported?, but a cached "no seats" is probed again first.
|
|
97
|
+
# For the destructive member-keyed statements: on a table migrated
|
|
98
|
+
# under this process their SQL without `connection_id IS NULL`
|
|
99
|
+
# succeeds silently and deletes seat rows.
|
|
100
|
+
def seats_supported_now?
|
|
101
|
+
return true if seats_supported?
|
|
102
|
+
|
|
103
|
+
reset_schema_cache!
|
|
104
|
+
seats_supported?
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def reset_schema_cache!
|
|
108
|
+
@schema_mutex.synchronize do
|
|
109
|
+
@probed_at = nil
|
|
110
|
+
@seats_supported = nil
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Deletes every seat row (connection_id IS NOT NULL) that has not
|
|
115
|
+
# been touched within its stream's ttl and returns the deleted
|
|
116
|
+
# records, each with its "stream_name". Member-keyed rows are left
|
|
117
|
+
# alone. `ttl_for` maps a stream name to seconds; `min_ttl` is the
|
|
118
|
+
# smallest ttl configured, used to narrow the candidates in SQL.
|
|
119
|
+
# The DELETE re-checks the cutoff (a touch between the two
|
|
120
|
+
# statements keeps the row) and claims each row atomically, so
|
|
121
|
+
# concurrent sweepers in other processes never report a row twice.
|
|
122
|
+
def expire_seats!(ttl_for:, min_ttl:)
|
|
123
|
+
return [] unless seats_supported?
|
|
124
|
+
|
|
125
|
+
candidates = connection.exec_params(<<~SQL, [min_ttl])
|
|
126
|
+
SELECT stream_name, connection_id FROM pgbus_presence_members
|
|
127
|
+
WHERE connection_id IS NOT NULL AND last_seen_at < NOW() - make_interval(secs => $1)
|
|
128
|
+
SQL
|
|
129
|
+
candidates.to_a.group_by { |row| row["stream_name"] }.flat_map do |stream_name, rows|
|
|
130
|
+
expire_stream_seats(stream_name, rows.map { |row| row["connection_id"] }, ttl_for.call(stream_name))
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def connection
|
|
135
|
+
# Respect multi-database setups (connects_to). When
|
|
136
|
+
# Pgbus.configuration.connects_to is set, the pgbus tables
|
|
137
|
+
# live in a separate database accessed via Pgbus::BusRecord;
|
|
138
|
+
# the default path uses ActiveRecord::Base. Matches the
|
|
139
|
+
# canonical pattern in Pgbus::Process::QueueLock#connection
|
|
140
|
+
# and Pgbus::Configuration's connection probe.
|
|
141
|
+
unless defined?(::ActiveRecord::Base)
|
|
142
|
+
raise Pgbus::ConfigurationError,
|
|
143
|
+
"Pgbus::Streams::Presence requires ActiveRecord (no AR connection available)"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
if Pgbus.configuration.connects_to
|
|
147
|
+
Pgbus::BusRecord.connection.raw_connection
|
|
148
|
+
else
|
|
149
|
+
::ActiveRecord::Base.connection.raw_connection
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Reports a seat join/leave to streams_presence_on_change (issue #515).
|
|
154
|
+
def report_change(stream_name, type, record)
|
|
155
|
+
Pgbus.configuration.notify_presence_change(
|
|
156
|
+
stream_name,
|
|
157
|
+
{ type: type, connection_id: record["connection_id"], seat: record["seat"], member_id: record["id"] }
|
|
158
|
+
)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# A Ruby Array of Strings as a Postgres text[] literal.
|
|
162
|
+
def text_array(values)
|
|
163
|
+
"{#{values.map { |v| %("#{v.to_s.gsub(/["\\]/) { |c| "\\#{c}" }}") }.join(",")}}"
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def monotonic_now
|
|
167
|
+
::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
private
|
|
171
|
+
|
|
172
|
+
# Reads each ivar once: a concurrent reset_schema_cache! may nil them.
|
|
173
|
+
def schema_answer_fresh?
|
|
174
|
+
supported = @seats_supported
|
|
175
|
+
probed_at = @probed_at
|
|
176
|
+
return false if supported.nil? || probed_at.nil?
|
|
177
|
+
|
|
178
|
+
supported || monotonic_now - probed_at < SCHEMA_RECHECK_SECONDS
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def expire_stream_seats(stream_name, connection_ids, ttl)
|
|
182
|
+
rows = connection.exec_params(<<~SQL, [stream_name, text_array(connection_ids), ttl])
|
|
183
|
+
DELETE FROM pgbus_presence_members
|
|
184
|
+
WHERE stream_name = $1 AND connection_id = ANY($2::text[])
|
|
185
|
+
AND last_seen_at < NOW() - make_interval(secs => $3)
|
|
186
|
+
RETURNING #{SEAT_RECORD_COLUMNS}
|
|
187
|
+
SQL
|
|
188
|
+
rows.to_a.map do |row|
|
|
189
|
+
record = Record.build(row)
|
|
190
|
+
report_change(stream_name, :leave, record)
|
|
191
|
+
record.merge("stream_name" => stream_name)
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Turns a pgbus_presence_members row into the public record Hash.
|
|
197
|
+
# Seat columns appear only when the row has them.
|
|
198
|
+
module Record
|
|
199
|
+
def self.build(row)
|
|
200
|
+
record = {
|
|
201
|
+
"id" => row["member_id"],
|
|
202
|
+
"metadata" => parse_json(row["metadata"]),
|
|
203
|
+
"joined_at" => row["joined_at"],
|
|
204
|
+
"last_seen_at" => row["last_seen_at"]
|
|
205
|
+
}
|
|
206
|
+
return record unless row.key?("connection_id")
|
|
207
|
+
|
|
208
|
+
record.merge("connection_id" => row["connection_id"], "seat" => row["seat"],
|
|
209
|
+
"state" => parse_json(row["state"]))
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def self.parse_json(value)
|
|
213
|
+
return value if value.is_a?(Hash)
|
|
214
|
+
return {} if value.nil? || value.empty?
|
|
215
|
+
|
|
216
|
+
JSON.parse(value)
|
|
217
|
+
rescue JSON::ParserError => e
|
|
218
|
+
# Resilience fallback: return an empty hash rather than
|
|
219
|
+
# crashing the presence member list on one corrupt row. Debug
|
|
220
|
+
# log so operators can still find the corruption without
|
|
221
|
+
# impacting production throughput (debug is off by default).
|
|
222
|
+
# Truncate the value so a huge metadata payload doesn't bloat
|
|
223
|
+
# the log line.
|
|
224
|
+
truncated = value.to_s[0, 200]
|
|
225
|
+
Pgbus.logger&.debug do
|
|
226
|
+
"[Pgbus::Streams::Presence] parse_metadata failed (#{e.class}: #{e.message}); raw: #{truncated.inspect}"
|
|
227
|
+
end
|
|
228
|
+
{}
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
46
232
|
def initialize(stream)
|
|
47
233
|
@stream = stream
|
|
48
234
|
end
|
|
49
235
|
|
|
236
|
+
# True when the table carries seats (see .seats_supported?).
|
|
237
|
+
def seats?
|
|
238
|
+
self.class.seats_supported?
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# True when the table carries seats, re-probing a cached "no".
|
|
242
|
+
def seats_now?
|
|
243
|
+
self.class.seats_supported_now?
|
|
244
|
+
end
|
|
245
|
+
|
|
50
246
|
# Adds (or refreshes) a member on this stream. Idempotent: calling
|
|
51
247
|
# join twice with the same member_id updates last_seen_at and
|
|
52
248
|
# metadata without creating a duplicate row. Yields the member
|
|
53
249
|
# hash to the optional block so the caller can render an
|
|
54
250
|
# `<turbo-stream action="append">` to broadcast the join.
|
|
55
|
-
|
|
251
|
+
#
|
|
252
|
+
# With `connection_id:` (seat tables only) the row is the
|
|
253
|
+
# connection's: one per SSE connection, carrying the client's `seat`.
|
|
254
|
+
def join(member_id:, metadata: {}, connection_id: nil, seat: nil)
|
|
56
255
|
member_id = member_id.to_s
|
|
57
|
-
record =
|
|
256
|
+
record = if connection_id
|
|
257
|
+
upsert_seat(member_id, metadata, connection_id.to_s, seat&.to_s)
|
|
258
|
+
else
|
|
259
|
+
upsert_member(member_id, metadata)
|
|
260
|
+
end
|
|
261
|
+
self.class.report_change(@stream.name, :join, record) if connection_id
|
|
58
262
|
|
|
59
263
|
if block_given?
|
|
60
264
|
html = yield(record)
|
|
@@ -67,11 +271,19 @@ module Pgbus
|
|
|
67
271
|
# Removes a member. Yields the (former) member hash to the
|
|
68
272
|
# optional block so the caller can broadcast a `remove` action.
|
|
69
273
|
# Returns the deleted record, or nil if the member wasn't present.
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
record =
|
|
274
|
+
# Pass `connection_id:` instead to remove one connection's seat row.
|
|
275
|
+
def leave(member_id: nil, connection_id: nil)
|
|
276
|
+
record = if connection_id && member_id.nil?
|
|
277
|
+
delete_seat(connection_id.to_s)
|
|
278
|
+
elsif member_id && connection_id.nil?
|
|
279
|
+
delete_member(member_id.to_s)
|
|
280
|
+
else
|
|
281
|
+
raise ArgumentError, "leave takes exactly one of member_id: or connection_id:"
|
|
282
|
+
end
|
|
73
283
|
return nil unless record
|
|
74
284
|
|
|
285
|
+
self.class.report_change(@stream.name, :leave, record) if connection_id
|
|
286
|
+
|
|
75
287
|
if block_given?
|
|
76
288
|
html = yield(record)
|
|
77
289
|
@stream.broadcast(html) if html.is_a?(String) && !html.empty?
|
|
@@ -84,32 +296,53 @@ module Pgbus
|
|
|
84
296
|
# Used as a heartbeat mechanism: clients ping touch periodically
|
|
85
297
|
# to stay in the member list, and a sweeper expires anyone who
|
|
86
298
|
# hasn't pinged in N seconds.
|
|
87
|
-
|
|
299
|
+
#
|
|
300
|
+
# With `connection_id:` (a String or an Array of them) it refreshes
|
|
301
|
+
# those connections' seat rows in one statement and returns the ids
|
|
302
|
+
# it found — a missing id means the row was expired while the
|
|
303
|
+
# connection was still alive, and the caller can re-join it.
|
|
304
|
+
def touch(member_id: nil, connection_id: nil)
|
|
305
|
+
raise ArgumentError, "touch takes exactly one of member_id: or connection_id:" unless member_id.nil? ^ connection_id.nil?
|
|
306
|
+
|
|
307
|
+
return touch_seats(Array(connection_id)) if connection_id
|
|
308
|
+
|
|
88
309
|
member_id = member_id.to_s
|
|
89
310
|
connection.exec_params(<<~SQL, [@stream.name, member_id])
|
|
90
311
|
UPDATE pgbus_presence_members
|
|
91
312
|
SET last_seen_at = NOW()
|
|
92
|
-
WHERE stream_name = $1 AND member_id = $2
|
|
313
|
+
WHERE stream_name = $1 AND member_id = $2#{" AND connection_id IS NULL" if seats_now?}
|
|
93
314
|
SQL
|
|
94
315
|
end
|
|
95
316
|
|
|
317
|
+
# Merges `state` into one connection's seat row (keys not given keep
|
|
318
|
+
# their value). Returns the updated record, or nil when the
|
|
319
|
+
# connection has no row on this stream. Does not broadcast: the
|
|
320
|
+
# caller renders whatever its viewers should see.
|
|
321
|
+
def set_state(connection_id:, state:)
|
|
322
|
+
raise ArgumentError, "set_state takes a Hash (got #{state.class})" unless state.is_a?(Hash)
|
|
323
|
+
|
|
324
|
+
rows = connection.exec_params(<<~SQL, [@stream.name, connection_id.to_s, JSON.generate(state)])
|
|
325
|
+
UPDATE pgbus_presence_members
|
|
326
|
+
SET state = state || $3::jsonb, last_seen_at = NOW()
|
|
327
|
+
WHERE stream_name = $1 AND connection_id = $2
|
|
328
|
+
RETURNING #{SEAT_RECORD_COLUMNS}
|
|
329
|
+
SQL
|
|
330
|
+
row = rows.first
|
|
331
|
+
row && Record.build(row)
|
|
332
|
+
end
|
|
333
|
+
|
|
96
334
|
# Returns the current list of members on this stream as an
|
|
97
|
-
# array of hashes (id, metadata, joined_at, last_seen_at).
|
|
335
|
+
# array of hashes (id, metadata, joined_at, last_seen_at). On a seat
|
|
336
|
+
# table each row is one seat and also carries connection_id, seat
|
|
337
|
+
# and state; group by "id" for one entry per member.
|
|
98
338
|
def members
|
|
99
339
|
rows = connection.exec_params(<<~SQL, [@stream.name])
|
|
100
|
-
SELECT
|
|
340
|
+
SELECT #{seats? ? SEAT_RECORD_COLUMNS : RECORD_COLUMNS}
|
|
101
341
|
FROM pgbus_presence_members
|
|
102
342
|
WHERE stream_name = $1
|
|
103
343
|
ORDER BY joined_at
|
|
104
344
|
SQL
|
|
105
|
-
rows.to_a.map
|
|
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
|
|
345
|
+
rows.to_a.map { |row| Record.build(row) }
|
|
113
346
|
end
|
|
114
347
|
|
|
115
348
|
# Returns the count of current members. Faster than members.size
|
|
@@ -128,88 +361,91 @@ module Pgbus
|
|
|
128
361
|
#
|
|
129
362
|
# Atomically claims the deletion via DELETE RETURNING so multiple
|
|
130
363
|
# workers running the sweep concurrently won't double-emit leave
|
|
131
|
-
# events.
|
|
364
|
+
# events. Yields each expired record when a block is given.
|
|
132
365
|
def sweep!(older_than:)
|
|
133
366
|
rows = connection.exec_params(<<~SQL, [@stream.name, older_than])
|
|
134
367
|
DELETE FROM pgbus_presence_members
|
|
135
|
-
WHERE stream_name = $1 AND last_seen_at < $2
|
|
136
|
-
RETURNING
|
|
368
|
+
WHERE stream_name = $1 AND last_seen_at < $2#{" AND connection_id IS NULL" if seats_now?}
|
|
369
|
+
RETURNING #{RECORD_COLUMNS}
|
|
137
370
|
SQL
|
|
138
|
-
rows.to_a
|
|
371
|
+
rows = rows.to_a
|
|
372
|
+
rows.each { |row| yield Record.build(row) } if block_given?
|
|
373
|
+
rows.size
|
|
139
374
|
end
|
|
140
375
|
|
|
141
376
|
private
|
|
142
377
|
|
|
143
|
-
|
|
378
|
+
# A process that last probed the pre-seat schema sends the old conflict
|
|
379
|
+
# target, which Postgres rejects once add_presence_seats has replaced
|
|
380
|
+
# the unique index with a partial one. Re-probe and retry once.
|
|
381
|
+
def upsert_member(member_id, metadata, retried: false)
|
|
382
|
+
upsert_member_once(member_id, metadata)
|
|
383
|
+
rescue PG::InvalidColumnReference
|
|
384
|
+
raise if retried
|
|
385
|
+
|
|
386
|
+
self.class.reset_schema_cache!
|
|
387
|
+
upsert_member(member_id, metadata, retried: true)
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
def upsert_member_once(member_id, metadata)
|
|
391
|
+
conflict = seats? ? "(stream_name, member_id) WHERE connection_id IS NULL" : "(stream_name, member_id)"
|
|
144
392
|
rows = connection.exec_params(<<~SQL, [@stream.name, member_id, JSON.generate(metadata)])
|
|
145
393
|
INSERT INTO pgbus_presence_members (stream_name, member_id, metadata, joined_at, last_seen_at)
|
|
146
394
|
VALUES ($1, $2, $3::jsonb, NOW(), NOW())
|
|
147
|
-
ON CONFLICT
|
|
395
|
+
ON CONFLICT #{conflict}
|
|
148
396
|
DO UPDATE SET metadata = EXCLUDED.metadata, last_seen_at = NOW()
|
|
149
|
-
RETURNING
|
|
397
|
+
RETURNING #{RECORD_COLUMNS}
|
|
150
398
|
SQL
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
399
|
+
Record.build(rows.first)
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def upsert_seat(member_id, metadata, connection_id, seat)
|
|
403
|
+
rows = connection.exec_params(<<~SQL, [@stream.name, member_id, JSON.generate(metadata), connection_id, seat])
|
|
404
|
+
INSERT INTO pgbus_presence_members
|
|
405
|
+
(stream_name, member_id, metadata, connection_id, seat, joined_at, last_seen_at)
|
|
406
|
+
VALUES ($1, $2, $3::jsonb, $4, $5, NOW(), NOW())
|
|
407
|
+
ON CONFLICT (stream_name, connection_id) WHERE connection_id IS NOT NULL
|
|
408
|
+
DO UPDATE SET member_id = EXCLUDED.member_id, metadata = EXCLUDED.metadata,
|
|
409
|
+
seat = EXCLUDED.seat, last_seen_at = NOW()
|
|
410
|
+
RETURNING #{SEAT_RECORD_COLUMNS}
|
|
411
|
+
SQL
|
|
412
|
+
Record.build(rows.first)
|
|
158
413
|
end
|
|
159
414
|
|
|
160
415
|
def delete_member(member_id)
|
|
161
416
|
rows = connection.exec_params(<<~SQL, [@stream.name, member_id])
|
|
162
417
|
DELETE FROM pgbus_presence_members
|
|
163
|
-
WHERE stream_name = $1 AND member_id = $2
|
|
164
|
-
RETURNING
|
|
418
|
+
WHERE stream_name = $1 AND member_id = $2#{" AND connection_id IS NULL" if seats_now?}
|
|
419
|
+
RETURNING #{RECORD_COLUMNS}
|
|
165
420
|
SQL
|
|
166
421
|
row = rows.first
|
|
167
|
-
|
|
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
|
-
{}
|
|
422
|
+
row && Record.build(row)
|
|
194
423
|
end
|
|
195
424
|
|
|
196
|
-
def
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
"Pgbus::Streams::Presence requires ActiveRecord (no AR connection available)"
|
|
206
|
-
end
|
|
425
|
+
def delete_seat(connection_id)
|
|
426
|
+
rows = connection.exec_params(<<~SQL, [@stream.name, connection_id])
|
|
427
|
+
DELETE FROM pgbus_presence_members
|
|
428
|
+
WHERE stream_name = $1 AND connection_id = $2
|
|
429
|
+
RETURNING #{SEAT_RECORD_COLUMNS}
|
|
430
|
+
SQL
|
|
431
|
+
row = rows.first
|
|
432
|
+
row && Record.build(row)
|
|
433
|
+
end
|
|
207
434
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
435
|
+
def touch_seats(connection_ids)
|
|
436
|
+
return [] if connection_ids.empty?
|
|
437
|
+
|
|
438
|
+
rows = connection.exec_params(<<~SQL, [@stream.name, self.class.text_array(connection_ids)])
|
|
439
|
+
UPDATE pgbus_presence_members
|
|
440
|
+
SET last_seen_at = NOW()
|
|
441
|
+
WHERE stream_name = $1 AND connection_id = ANY($2::text[])
|
|
442
|
+
RETURNING connection_id
|
|
443
|
+
SQL
|
|
444
|
+
rows.to_a.map { |row| row["connection_id"] }
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
def connection
|
|
448
|
+
self.class.connection
|
|
213
449
|
end
|
|
214
450
|
end
|
|
215
451
|
end
|
data/lib/pgbus/version.rb
CHANGED
data/lib/pgbus/web/stream_app.rb
CHANGED
|
@@ -16,13 +16,16 @@ module Pgbus
|
|
|
16
16
|
# Call flow (happy path, Puma 6.1+ hijack):
|
|
17
17
|
# 1. Parse the signed name from PATH_INFO
|
|
18
18
|
# 2. Verify it via Pgbus::Streams::SignedName (raises → 404)
|
|
19
|
-
# 3. Run the authorize hook
|
|
19
|
+
# 3. Run the authorize hook: authorize:, else config.streams_authorize
|
|
20
|
+
# (falsy → 403)
|
|
20
21
|
# 4. Parse the cursor from ?since= or Last-Event-ID
|
|
21
22
|
# 5. Check streams_max_connections per worker (over → 503)
|
|
22
23
|
# 6. Check rack.hijack? (missing → 501)
|
|
23
24
|
# 7. Hijack, write the HTTP response line + SSE headers + opening
|
|
24
25
|
# comment directly to the socket
|
|
25
|
-
# 8. Build a Connection, hand to Streamer.current.register(...)
|
|
26
|
+
# 8. Build a Connection, hand to Streamer.current.register(...). The
|
|
27
|
+
# Dispatcher writes the pgbus:connected frame once the connection
|
|
28
|
+
# is registered and its presence seat joined (issue #521)
|
|
26
29
|
# 9. Return [-1, {}, []] (Puma's async protocol)
|
|
27
30
|
#
|
|
28
31
|
# On errors, returns a normal [status, headers, body] response so
|
|
@@ -31,15 +34,22 @@ module Pgbus
|
|
|
31
34
|
# full request lifecycle.
|
|
32
35
|
class StreamApp
|
|
33
36
|
PATH_PREFIX = "/pgbus/streams"
|
|
37
|
+
# The client's per-tab presence seat (?seat=, issue #515): an opaque id
|
|
38
|
+
# minted by <pgbus-stream-source presence>. Anything else is dropped.
|
|
39
|
+
SEAT_FORMAT = /\A[A-Za-z0-9_-]{1,64}\z/
|
|
34
40
|
# EventSource reconnect delay handed to the streams_test_mode stub (24h).
|
|
35
41
|
TEST_MODE_RETRY_MS = 86_400_000
|
|
36
42
|
private_constant :PATH_PREFIX
|
|
37
43
|
|
|
44
|
+
# Used when neither authorize: nor config.streams_authorize is set.
|
|
45
|
+
ALLOW_ALL = ->(_env, _stream_name) { true }
|
|
46
|
+
private_constant :ALLOW_ALL
|
|
47
|
+
|
|
38
48
|
def initialize(streamer: nil, config: nil, logger: nil, authorize: nil)
|
|
39
49
|
@streamer_override = streamer
|
|
40
50
|
@config_override = config
|
|
41
51
|
@logger_override = logger
|
|
42
|
-
@authorize = authorize
|
|
52
|
+
@authorize = authorize
|
|
43
53
|
end
|
|
44
54
|
|
|
45
55
|
def call(env)
|
|
@@ -52,7 +62,7 @@ module Pgbus
|
|
|
52
62
|
stream_name = verify!(signed_name)
|
|
53
63
|
return not_found("invalid signed stream name") if stream_name.nil?
|
|
54
64
|
|
|
55
|
-
authorize_result =
|
|
65
|
+
authorize_result = authorize_hook.call(env, stream_name)
|
|
56
66
|
return forbidden unless authorize_result
|
|
57
67
|
|
|
58
68
|
# If authorize returned a non-boolean value (e.g. a User model),
|
|
@@ -64,14 +74,16 @@ module Pgbus
|
|
|
64
74
|
cursor = parse_cursor(env, request)
|
|
65
75
|
return bad_request("invalid cursor: #{cursor}") if cursor.is_a?(String)
|
|
66
76
|
|
|
77
|
+
seat = parse_seat(request)
|
|
78
|
+
|
|
67
79
|
return test_mode_stub if config.streams_test_mode
|
|
68
80
|
|
|
69
81
|
return over_capacity if streamer.registry.size >= config.streams_max_connections
|
|
70
82
|
|
|
71
83
|
if config.streams_falcon_streaming_body
|
|
72
|
-
streaming_body_response(stream_name: stream_name, since_id: cursor, context: context)
|
|
84
|
+
streaming_body_response(stream_name: stream_name, since_id: cursor, context: context, seat: seat)
|
|
73
85
|
elsif env["rack.hijack?"]
|
|
74
|
-
hijack_and_register(env, stream_name: stream_name, since_id: cursor, context: context)
|
|
86
|
+
hijack_and_register(env, stream_name: stream_name, since_id: cursor, context: context, seat: seat)
|
|
75
87
|
else
|
|
76
88
|
unsupported_server
|
|
77
89
|
end
|
|
@@ -114,7 +126,12 @@ module Pgbus
|
|
|
114
126
|
e.message
|
|
115
127
|
end
|
|
116
128
|
|
|
117
|
-
def
|
|
129
|
+
def parse_seat(request)
|
|
130
|
+
seat = request.params["seat"]
|
|
131
|
+
seat if seat.is_a?(String) && seat.ascii_only? && SEAT_FORMAT.match?(seat)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def hijack_and_register(env, stream_name:, since_id:, context: nil, seat: nil)
|
|
118
135
|
# Rack hijack is a one-time transition per the Rack spec. Call
|
|
119
136
|
# once, use the return value, and fall back to the side-effect
|
|
120
137
|
# `rack.hijack_io` variable only if the return was nil (some
|
|
@@ -124,7 +141,7 @@ module Pgbus
|
|
|
124
141
|
io = hijack_result || env["rack.hijack_io"]
|
|
125
142
|
|
|
126
143
|
connection_id = SecureRandom.hex(8)
|
|
127
|
-
write_headers(io, stream_name: stream_name, since_id: since_id
|
|
144
|
+
write_headers(io, stream_name: stream_name, since_id: since_id)
|
|
128
145
|
|
|
129
146
|
connection = Pgbus::Web::Streamer::Connection.new(
|
|
130
147
|
id: connection_id,
|
|
@@ -133,14 +150,15 @@ module Pgbus
|
|
|
133
150
|
since_id: since_id,
|
|
134
151
|
writer: Pgbus::Web::Streamer::IoWriter,
|
|
135
152
|
write_deadline_ms: config.streams_write_deadline_ms,
|
|
136
|
-
context: context
|
|
153
|
+
context: context,
|
|
154
|
+
seat: seat
|
|
137
155
|
)
|
|
138
156
|
streamer.register(connection)
|
|
139
157
|
|
|
140
158
|
[-1, {}, []]
|
|
141
159
|
end
|
|
142
160
|
|
|
143
|
-
def streaming_body_response(stream_name:, since_id:, context: nil)
|
|
161
|
+
def streaming_body_response(stream_name:, since_id:, context: nil, seat: nil)
|
|
144
162
|
require "protocol/http/body/writable" unless defined?(::Protocol::HTTP::Body::Writable)
|
|
145
163
|
require_relative "streamer/falcon_connection" unless defined?(Pgbus::Web::Streamer::FalconConnection)
|
|
146
164
|
|
|
@@ -151,7 +169,6 @@ module Pgbus
|
|
|
151
169
|
body.write(Pgbus::Streams::Envelope.comment(
|
|
152
170
|
"pgbus stream open since_id=#{since_id} stream=#{stream_name}"
|
|
153
171
|
))
|
|
154
|
-
body.write(Pgbus::Streams::Envelope.connected(id: connection_id))
|
|
155
172
|
|
|
156
173
|
connection = Pgbus::Web::Streamer::FalconConnection.new(
|
|
157
174
|
id: connection_id,
|
|
@@ -159,7 +176,8 @@ module Pgbus
|
|
|
159
176
|
body: body,
|
|
160
177
|
since_id: since_id,
|
|
161
178
|
write_deadline_ms: config.streams_write_deadline_ms,
|
|
162
|
-
context: context
|
|
179
|
+
context: context,
|
|
180
|
+
seat: seat
|
|
163
181
|
)
|
|
164
182
|
streamer.register(connection)
|
|
165
183
|
|
|
@@ -174,11 +192,22 @@ module Pgbus
|
|
|
174
192
|
}
|
|
175
193
|
end
|
|
176
194
|
|
|
177
|
-
def write_headers(io, stream_name:, since_id
|
|
195
|
+
def write_headers(io, stream_name:, since_id:)
|
|
178
196
|
io.write(Pgbus::Streams::Envelope.http_response_headers)
|
|
179
197
|
io.write(Pgbus::Streams::Envelope.retry_directive(2_000))
|
|
180
198
|
io.write(Pgbus::Streams::Envelope.comment("pgbus stream open since_id=#{since_id} stream=#{stream_name}"))
|
|
181
|
-
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# An explicit authorize: wins; otherwise config.streams_authorize, read
|
|
202
|
+
# per request so the engine's mount (built when routes load) sees a hook
|
|
203
|
+
# set in an initializer that runs later (issue #522). Only nil means
|
|
204
|
+
# "unset": any other non-callable (false included) must reach #call and
|
|
205
|
+
# fail closed with a 500, never fall through to ALLOW_ALL.
|
|
206
|
+
def authorize_hook
|
|
207
|
+
return @authorize unless @authorize.nil?
|
|
208
|
+
|
|
209
|
+
hook = config.streams_authorize
|
|
210
|
+
hook.nil? ? ALLOW_ALL : hook
|
|
182
211
|
end
|
|
183
212
|
|
|
184
213
|
def streamer
|