pgbus 0.17.2 → 0.17.3

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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +4 -0
  3. data/README.md +35 -0
  4. data/Rakefile +26 -0
  5. data/app/assets/javascripts/pgbus/stream_source_element.js +34 -2
  6. data/app/assets/javascripts/pgbus/stream_source_element.min.js +6 -0
  7. data/app/assets/javascripts/pgbus/stream_source_element.min.js.map +1 -0
  8. data/app/controllers/pgbus/frontends_controller.rb +25 -11
  9. data/app/frontend/pgbus/application.min.js +2 -0
  10. data/app/frontend/pgbus/application.min.js.map +1 -0
  11. data/app/frontend/pgbus/modules/charts.js +24 -6
  12. data/app/frontend/pgbus/modules/charts.min.js +2 -0
  13. data/app/frontend/pgbus/modules/charts.min.js.map +1 -0
  14. data/app/frontend/pgbus/vendor/turbo.min.js +54 -0
  15. data/app/frontend/pgbus/vendor/turbo.min.js.map +1 -0
  16. data/app/views/layouts/pgbus/application.html.erb +0 -1
  17. data/config/routes.rb +1 -1
  18. data/lib/generators/pgbus/add_presence_generator.rb +2 -1
  19. data/lib/generators/pgbus/add_presence_seats_generator.rb +45 -0
  20. data/lib/generators/pgbus/templates/add_presence.rb.erb +32 -7
  21. data/lib/generators/pgbus/templates/add_presence_seats.rb.erb +38 -0
  22. data/lib/pgbus/configuration/presence_settings.rb +103 -0
  23. data/lib/pgbus/configuration.rb +4 -0
  24. data/lib/pgbus/engine.rb +13 -7
  25. data/lib/pgbus/generators/migration_detector.rb +9 -1
  26. data/lib/pgbus/streams/presence.rb +317 -81
  27. data/lib/pgbus/version.rb +1 -1
  28. data/lib/pgbus/web/stream_app.rb +18 -6
  29. data/lib/pgbus/web/streamer/connection.rb +7 -1
  30. data/lib/pgbus/web/streamer/falcon_connection.rb +7 -1
  31. data/lib/pgbus/web/streamer/heartbeat.rb +9 -3
  32. data/lib/pgbus/web/streamer/instance.rb +2 -1
  33. data/lib/pgbus/web/streamer/presence_tracker.rb +155 -0
  34. data/lib/pgbus/web/streamer/stream_event_dispatcher.rb +8 -37
  35. metadata +13 -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
- # NOT included in this v1:
39
- # - Automatic connection-driven join/leave (the application must
40
- # call join/leave explicitly).
41
- # - Automatic stale-member sweeping (call Presence.sweep!(stream)
42
- # from a cron or after a heartbeat to expire idle members).
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
- def join(member_id:, metadata: {})
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 = upsert_member(member_id, metadata)
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
- def leave(member_id:)
71
- member_id = member_id.to_s
72
- record = delete_member(member_id)
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
- def touch(member_id:)
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 member_id, metadata, joined_at, last_seen_at
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 do |row|
106
- {
107
- "id" => row["member_id"],
108
- "metadata" => parse_metadata(row["metadata"]),
109
- "joined_at" => row["joined_at"],
110
- "last_seen_at" => row["last_seen_at"]
111
- }
112
- end
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 member_id, metadata, joined_at, last_seen_at
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.size
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
- def upsert_member(member_id, metadata)
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 (stream_name, member_id)
395
+ ON CONFLICT #{conflict}
148
396
  DO UPDATE SET metadata = EXCLUDED.metadata, last_seen_at = NOW()
149
- RETURNING member_id, metadata, joined_at, last_seen_at
397
+ RETURNING #{RECORD_COLUMNS}
150
398
  SQL
151
- row = rows.first
152
- {
153
- "id" => row["member_id"],
154
- "metadata" => parse_metadata(row["metadata"]),
155
- "joined_at" => row["joined_at"],
156
- "last_seen_at" => row["last_seen_at"]
157
- }
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 member_id, metadata, joined_at, last_seen_at
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
- return nil unless row
168
-
169
- {
170
- "id" => row["member_id"],
171
- "metadata" => parse_metadata(row["metadata"]),
172
- "joined_at" => row["joined_at"],
173
- "last_seen_at" => row["last_seen_at"]
174
- }
175
- end
176
-
177
- def parse_metadata(value)
178
- return value if value.is_a?(Hash)
179
- return {} if value.nil? || value.empty?
180
-
181
- JSON.parse(value)
182
- rescue JSON::ParserError => e
183
- # Resilience fallback: return an empty hash rather than
184
- # crashing the presence member list on one corrupt row. Debug
185
- # log so operators can still find the corruption without
186
- # impacting production throughput (debug is off by default).
187
- # Truncate the value so a huge metadata payload doesn't bloat
188
- # the log line.
189
- truncated = value.to_s[0, 200]
190
- Pgbus.logger&.debug do
191
- "[Pgbus::Streams::Presence] parse_metadata failed (#{e.class}: #{e.message}); raw: #{truncated.inspect}"
192
- end
193
- {}
422
+ row && Record.build(row)
194
423
  end
195
424
 
196
- def connection
197
- # Respect multi-database setups (connects_to). When
198
- # Pgbus.configuration.connects_to is set, the pgbus tables
199
- # live in a separate database accessed via Pgbus::BusRecord;
200
- # the default path uses ActiveRecord::Base. Matches the
201
- # canonical pattern in Pgbus::Process::QueueLock#connection
202
- # and Pgbus::Configuration's connection probe.
203
- unless defined?(::ActiveRecord::Base)
204
- raise Pgbus::ConfigurationError,
205
- "Pgbus::Streams::Presence requires ActiveRecord (no AR connection available)"
206
- end
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
- if Pgbus.configuration.connects_to
209
- Pgbus::BusRecord.connection.raw_connection
210
- else
211
- ::ActiveRecord::Base.connection.raw_connection
212
- end
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pgbus
4
- VERSION = "0.17.2"
4
+ VERSION = "0.17.3"
5
5
  end
@@ -31,6 +31,9 @@ module Pgbus
31
31
  # full request lifecycle.
32
32
  class StreamApp
33
33
  PATH_PREFIX = "/pgbus/streams"
34
+ # The client's per-tab presence seat (?seat=, issue #515): an opaque id
35
+ # minted by <pgbus-stream-source presence>. Anything else is dropped.
36
+ SEAT_FORMAT = /\A[A-Za-z0-9_-]{1,64}\z/
34
37
  # EventSource reconnect delay handed to the streams_test_mode stub (24h).
35
38
  TEST_MODE_RETRY_MS = 86_400_000
36
39
  private_constant :PATH_PREFIX
@@ -64,14 +67,16 @@ module Pgbus
64
67
  cursor = parse_cursor(env, request)
65
68
  return bad_request("invalid cursor: #{cursor}") if cursor.is_a?(String)
66
69
 
70
+ seat = parse_seat(request)
71
+
67
72
  return test_mode_stub if config.streams_test_mode
68
73
 
69
74
  return over_capacity if streamer.registry.size >= config.streams_max_connections
70
75
 
71
76
  if config.streams_falcon_streaming_body
72
- streaming_body_response(stream_name: stream_name, since_id: cursor, context: context)
77
+ streaming_body_response(stream_name: stream_name, since_id: cursor, context: context, seat: seat)
73
78
  elsif env["rack.hijack?"]
74
- hijack_and_register(env, stream_name: stream_name, since_id: cursor, context: context)
79
+ hijack_and_register(env, stream_name: stream_name, since_id: cursor, context: context, seat: seat)
75
80
  else
76
81
  unsupported_server
77
82
  end
@@ -114,7 +119,12 @@ module Pgbus
114
119
  e.message
115
120
  end
116
121
 
117
- def hijack_and_register(env, stream_name:, since_id:, context: nil)
122
+ def parse_seat(request)
123
+ seat = request.params["seat"]
124
+ seat if seat.is_a?(String) && seat.ascii_only? && SEAT_FORMAT.match?(seat)
125
+ end
126
+
127
+ def hijack_and_register(env, stream_name:, since_id:, context: nil, seat: nil)
118
128
  # Rack hijack is a one-time transition per the Rack spec. Call
119
129
  # once, use the return value, and fall back to the side-effect
120
130
  # `rack.hijack_io` variable only if the return was nil (some
@@ -133,14 +143,15 @@ module Pgbus
133
143
  since_id: since_id,
134
144
  writer: Pgbus::Web::Streamer::IoWriter,
135
145
  write_deadline_ms: config.streams_write_deadline_ms,
136
- context: context
146
+ context: context,
147
+ seat: seat
137
148
  )
138
149
  streamer.register(connection)
139
150
 
140
151
  [-1, {}, []]
141
152
  end
142
153
 
143
- def streaming_body_response(stream_name:, since_id:, context: nil)
154
+ def streaming_body_response(stream_name:, since_id:, context: nil, seat: nil)
144
155
  require "protocol/http/body/writable" unless defined?(::Protocol::HTTP::Body::Writable)
145
156
  require_relative "streamer/falcon_connection" unless defined?(Pgbus::Web::Streamer::FalconConnection)
146
157
 
@@ -159,7 +170,8 @@ module Pgbus
159
170
  body: body,
160
171
  since_id: since_id,
161
172
  write_deadline_ms: config.streams_write_deadline_ms,
162
- context: context
173
+ context: context,
174
+ seat: seat
163
175
  )
164
176
  streamer.register(connection)
165
177
 
@@ -14,10 +14,15 @@ 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, :context
17
+ # The client's per-tab presence seat (?seat=), or nil (issue #515).
18
+ attr_reader :seat
17
19
  # The presence member id this connection auto-joined as, or nil for
18
20
  # non-presence streams / anonymous connections. Set by the
19
21
  # Dispatcher on connect; read on disconnect and heartbeat touch.
20
22
  attr_accessor :presence_member
23
+ # True when presence_member joined as a seat row, false when as a
24
+ # member-keyed row (table not yet migrated). Leave follows the join.
25
+ attr_accessor :presence_seat
21
26
 
22
27
  # last_msg_id_sent is the ONE field whose writer can cross threads: with
23
28
  # streams_writer_threads > 0 (issue #321) the OutboundPump worker thread
@@ -31,7 +36,7 @@ module Pgbus
31
36
  @last_msg_id_sent.get
32
37
  end
33
38
 
34
- def initialize(id:, stream_name:, io:, since_id:, writer:, write_deadline_ms:, context: nil)
39
+ def initialize(id:, stream_name:, io:, since_id:, writer:, write_deadline_ms:, context: nil, seat: nil)
35
40
  @id = id
36
41
  @stream_name = stream_name
37
42
  @io = io
@@ -50,6 +55,7 @@ module Pgbus
50
55
  # registry when evaluating visible_to predicates. Defaults to
51
56
  # nil for tests that don't need audience filtering.
52
57
  @context = context
58
+ @seat = seat
53
59
  end
54
60
 
55
61
  # deadline_ms defaults to the connection's own write deadline so every
@@ -13,7 +13,12 @@ module Pgbus
13
13
  # is fiber-safe under Falcon's scheduler.
14
14
  class FalconConnection
15
15
  attr_reader :id, :stream_name, :io, :mutex, :context
16
+ # The client's per-tab presence seat (?seat=), or nil (issue #515).
17
+ attr_reader :seat
16
18
  attr_accessor :presence_member
19
+ # True when presence_member joined as a seat row, false when as a
20
+ # member-keyed row (table not yet migrated). Leave follows the join.
21
+ attr_accessor :presence_seat
17
22
 
18
23
  # last_msg_id_sent mirrors Connection's cross-thread cursor (issue #321):
19
24
  # a Concurrent::AtomicReference so the OutboundPump worker's advance and
@@ -23,7 +28,7 @@ module Pgbus
23
28
  @last_msg_id_sent.get
24
29
  end
25
30
 
26
- def initialize(id:, stream_name:, body:, since_id:, write_deadline_ms:, context: nil)
31
+ def initialize(id:, stream_name:, body:, since_id:, write_deadline_ms:, context: nil, seat: nil)
27
32
  @id = id
28
33
  @stream_name = stream_name
29
34
  @body = body
@@ -37,6 +42,7 @@ module Pgbus
37
42
  @created_at = monotonic
38
43
  @last_write_at = @created_at
39
44
  @context = context
45
+ @seat = seat
40
46
  end
41
47
 
42
48
  # deadline_ms is accepted for duck-type parity with Connection#enqueue
@@ -26,13 +26,18 @@ module Pgbus
26
26
  # the Connection's own mutex, so concurrent dispatcher + heartbeat
27
27
  # writes are safe.
28
28
  class Heartbeat
29
- def initialize(registry:, dispatch_queue:, interval:, idle_timeout:, logger: Pgbus.logger, clock: nil)
29
+ # presence_expiry: true (streams_presence_patterns is set) posts the
30
+ # presence touch every tick, even with no presence connection here,
31
+ # so the dispatcher can expire seats left by other processes.
32
+ def initialize(registry:, dispatch_queue:, interval:, idle_timeout:, logger: Pgbus.logger, clock: nil,
33
+ presence_expiry: false)
30
34
  @registry = registry
31
35
  @queue = dispatch_queue
32
36
  @interval = interval
33
37
  @idle_timeout = idle_timeout
34
38
  @logger = logger
35
39
  @clock = clock || -> { ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) }
40
+ @presence_expiry = presence_expiry
36
41
  @running = false
37
42
  @thread = nil
38
43
  @wake = ConditionVariable.new
@@ -122,9 +127,10 @@ module Pgbus
122
127
  # Posts one batched touch per tick so the dispatcher (which owns AR
123
128
  # connection release) refreshes last_seen_at for live presence
124
129
  # members, keeping them out of the sweeper. No message when no
125
- # connection has presence — avoids waking the dispatcher for nothing.
130
+ # connection has presence and seat expiry is off — avoids waking the
131
+ # dispatcher for nothing.
126
132
  def enqueue_presence_touch(connections)
127
- return if connections.empty?
133
+ return if connections.empty? && !@presence_expiry
128
134
 
129
135
  @queue << StreamEventDispatcher::PresenceTouchMessage.new(connections: connections)
130
136
  end
@@ -74,7 +74,8 @@ module Pgbus
74
74
  dispatch_queue: @dispatch_queue,
75
75
  interval: @config.streams_heartbeat_interval,
76
76
  idle_timeout: @config.streams_idle_timeout,
77
- logger: @logger
77
+ logger: @logger,
78
+ presence_expiry: Array(@config.streams_presence_patterns).any?
78
79
  )
79
80
  @started = false
80
81
  @shutdown_mutex = Mutex.new