pgbus 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +15 -0
  3. data/README.md +238 -0
  4. data/Rakefile +8 -1
  5. data/app/controllers/pgbus/insights_controller.rb +6 -0
  6. data/app/helpers/pgbus/streams_helper.rb +115 -0
  7. data/app/javascript/pgbus/stream_source_element.js +212 -0
  8. data/app/models/pgbus/stream_stat.rb +118 -0
  9. data/app/views/pgbus/insights/show.html.erb +59 -0
  10. data/config/locales/en.yml +16 -0
  11. data/config/routes.rb +11 -0
  12. data/lib/generators/pgbus/add_presence_generator.rb +55 -0
  13. data/lib/generators/pgbus/add_stream_stats_generator.rb +54 -0
  14. data/lib/generators/pgbus/templates/add_presence.rb.erb +26 -0
  15. data/lib/generators/pgbus/templates/add_stream_stats.rb.erb +18 -0
  16. data/lib/pgbus/client/ensure_stream_queue.rb +54 -0
  17. data/lib/pgbus/client/read_after.rb +100 -0
  18. data/lib/pgbus/client.rb +6 -0
  19. data/lib/pgbus/configuration/capsule_dsl.rb +6 -20
  20. data/lib/pgbus/configuration.rb +126 -14
  21. data/lib/pgbus/engine.rb +31 -0
  22. data/lib/pgbus/process/dispatcher.rb +62 -4
  23. data/lib/pgbus/streams/cursor.rb +71 -0
  24. data/lib/pgbus/streams/envelope.rb +58 -0
  25. data/lib/pgbus/streams/filters.rb +98 -0
  26. data/lib/pgbus/streams/presence.rb +216 -0
  27. data/lib/pgbus/streams/signed_name.rb +69 -0
  28. data/lib/pgbus/streams/turbo_broadcastable.rb +53 -0
  29. data/lib/pgbus/streams/watermark_cache_middleware.rb +28 -0
  30. data/lib/pgbus/streams.rb +151 -0
  31. data/lib/pgbus/version.rb +1 -1
  32. data/lib/pgbus/web/data_source.rb +29 -0
  33. data/lib/pgbus/web/stream_app.rb +179 -0
  34. data/lib/pgbus/web/streamer/connection.rb +122 -0
  35. data/lib/pgbus/web/streamer/dispatcher.rb +467 -0
  36. data/lib/pgbus/web/streamer/heartbeat.rb +105 -0
  37. data/lib/pgbus/web/streamer/instance.rb +176 -0
  38. data/lib/pgbus/web/streamer/io_writer.rb +73 -0
  39. data/lib/pgbus/web/streamer/listener.rb +228 -0
  40. data/lib/pgbus/web/streamer/registry.rb +103 -0
  41. data/lib/pgbus/web/streamer.rb +53 -0
  42. data/lib/pgbus.rb +28 -0
  43. data/lib/puma/plugin/pgbus_streams.rb +54 -0
  44. data/lib/tasks/pgbus_streams.rake +52 -0
  45. metadata +29 -1
@@ -84,6 +84,13 @@ module Pgbus
84
84
  attr_accessor :web_auth, :web_refresh_interval, :web_per_page, :web_live_updates, :web_data_source,
85
85
  :insights_default_minutes, :base_controller_class, :return_to_app_url
86
86
 
87
+ # Streams (turbo-rails replacement, SSE-based)
88
+ attr_accessor :streams_enabled, :streams_queue_prefix, :streams_signed_name_secret,
89
+ :streams_default_retention, :streams_retention, :streams_heartbeat_interval,
90
+ :streams_max_connections, :streams_idle_timeout, :streams_listen_health_check_ms,
91
+ :streams_write_deadline_ms, :streams_falcon_streaming_body,
92
+ :streams_stats_enabled
93
+
87
94
  def initialize
88
95
  @database_url = nil
89
96
  @connection_params = nil
@@ -150,6 +157,34 @@ module Pgbus
150
157
  @insights_default_minutes = 30 * 24 * 60 # 30 days
151
158
  @base_controller_class = "::ActionController::Base"
152
159
  @return_to_app_url = nil
160
+
161
+ @streams_enabled = true
162
+ @streams_queue_prefix = "pgbus_stream"
163
+ @streams_signed_name_secret = nil
164
+ @streams_default_retention = 5 * 60 # 5 minutes
165
+ @streams_retention = {}
166
+ @streams_heartbeat_interval = 15
167
+ @streams_max_connections = 2_000
168
+ @streams_idle_timeout = 3_600 # 1 hour
169
+ # 250ms — this value plays two roles: (1) the TCP keepalive
170
+ # interval for the streamer's PG LISTEN connection, and (2) the
171
+ # upper bound on how long Dispatcher#handle_connect waits for
172
+ # the Listener to acknowledge a synchronous ensure_listening
173
+ # call. 5s was unbounded enough to drop messages on a
174
+ # realistic subscribe burst; 250ms keeps the connect-path race
175
+ # window tight while still leaving headroom over a typical
176
+ # PG keepalive interval.
177
+ @streams_listen_health_check_ms = 250
178
+ @streams_write_deadline_ms = 5_000
179
+ @streams_falcon_streaming_body = false
180
+ # Opt-in: when true, the Dispatcher writes one row to
181
+ # pgbus_stream_stats per broadcast/connect/disconnect. Default
182
+ # off because stream event volume can be much higher than job
183
+ # volume and the Insights surface is only useful if operators
184
+ # actually look at it. Separate from #stats_enabled (which
185
+ # gates pgbus_job_stats recording) on purpose — operators
186
+ # usually want job stats on and stream stats off, or vice versa.
187
+ @streams_stats_enabled = false
153
188
  end
154
189
 
155
190
  def queue_name(name)
@@ -208,9 +243,39 @@ module Pgbus
208
243
  raise ArgumentError, "insights_default_minutes must be a positive integer"
209
244
  end
210
245
 
246
+ validate_streams!
247
+
211
248
  self
212
249
  end
213
250
 
251
+ def validate_streams!
252
+ unless streams_default_retention.is_a?(Numeric) && streams_default_retention >= 0
253
+ raise ArgumentError, "streams_default_retention must be a non-negative number"
254
+ end
255
+
256
+ unless streams_max_connections.is_a?(Integer) && streams_max_connections.positive?
257
+ raise ArgumentError, "streams_max_connections must be a positive integer"
258
+ end
259
+
260
+ unless streams_heartbeat_interval.is_a?(Numeric) && streams_heartbeat_interval.positive?
261
+ raise ArgumentError, "streams_heartbeat_interval must be a positive number"
262
+ end
263
+
264
+ unless streams_idle_timeout.is_a?(Numeric) && streams_idle_timeout.positive?
265
+ raise ArgumentError, "streams_idle_timeout must be a positive number"
266
+ end
267
+
268
+ unless streams_listen_health_check_ms.is_a?(Integer) && streams_listen_health_check_ms.positive?
269
+ raise ArgumentError, "streams_listen_health_check_ms must be a positive integer"
270
+ end
271
+
272
+ unless streams_write_deadline_ms.is_a?(Integer) && streams_write_deadline_ms.positive?
273
+ raise ArgumentError, "streams_write_deadline_ms must be a positive integer"
274
+ end
275
+
276
+ raise ArgumentError, "streams_retention must be a Hash" unless streams_retention.is_a?(Hash)
277
+ end
278
+
214
279
  # Set the worker capsule list. Accepts:
215
280
  #
216
281
  # String — parsed via Pgbus::Configuration::CapsuleDSL into capsules
@@ -230,12 +295,34 @@ module Pgbus
230
295
  # dispatcher-only processes).
231
296
  #
232
297
  # Raises ArgumentError for any other type.
298
+ #
299
+ # NAMING SEMANTICS for the String form:
300
+ #
301
+ # The parser produces anonymous capsules (no :name). The setter then
302
+ # auto-assigns a :name to capsules whose first queue would yield a
303
+ # *unique* name across the parsed list AND is not the bare wildcard
304
+ # (`*`). Anything else stays anonymous.
305
+ #
306
+ # "critical: 5; default: 10" -> two NAMED capsules ("critical", "default")
307
+ # "*: 5" -> one anonymous capsule (wildcard never names)
308
+ # "*: 3; *: 3; *: 3" -> three anonymous capsules — legal,
309
+ # represents "3 forks all reading every
310
+ # queue", restoring the legacy YAML
311
+ # `5 × {queues: ["*"], threads: 3}` shape
312
+ # "default: 5; default: 3" -> two anonymous capsules — same logic
313
+ #
314
+ # The point of the carve-out is the legacy "I want N forks of the same
315
+ # worker pool" pattern: it must keep working since PGMQ tolerates it
316
+ # natively (multiple processes reading the same queue with FOR UPDATE
317
+ # SKIP LOCKED). The CLI's --capsule selector only matches NAMED
318
+ # capsules, so anonymous duplicates can't be ambiguously addressed.
233
319
  def workers=(value)
234
320
  @workers = case value
235
321
  when nil
236
322
  nil
237
323
  when String
238
- CapsuleDSL.parse(value).map { |entry| entry.merge(name: entry[:queues].first.to_s) }
324
+ parsed = CapsuleDSL.parse(value)
325
+ assign_auto_names(parsed)
239
326
  when Array
240
327
  value
241
328
  else
@@ -445,33 +532,58 @@ module Pgbus
445
532
  raw&.to_s
446
533
  end
447
534
 
448
- # Validates that no queue in +new_queues+ would overlap with any
449
- # existing capsule. The wildcard '*' counts as overlapping with EVERY
450
- # other queue (and vice versa) because at runtime '*' is expanded to
451
- # all known queues. Raises ArgumentError on overlap.
535
+ # Auto-assign :name to parsed capsules where the first queue token would
536
+ # yield a unique name and is not the bare wildcard. See the long comment
537
+ # on +workers=+ for the why. Returns the same array with :name merged in
538
+ # where applicable.
539
+ def assign_auto_names(parsed_capsules)
540
+ first_queue_counts = parsed_capsules.each_with_object(Hash.new(0)) do |capsule, h|
541
+ h[capsule[:queues].first] += 1
542
+ end
543
+
544
+ parsed_capsules.map do |capsule|
545
+ first = capsule[:queues].first
546
+ nameable = first != CapsuleDSL::WILDCARD && first_queue_counts[first] == 1
547
+ nameable ? capsule.merge(name: first.to_s) : capsule
548
+ end
549
+ end
550
+
551
+ # Validates that the new capsule (added via +c.capsule :name, ...+) does
552
+ # not overlap with any existing NAMED capsule. Anonymous capsules (parsed
553
+ # from the string DSL with auto-naming skipped, e.g. wildcards or
554
+ # would-collide first-queues) are intentionally invisible here — they
555
+ # represent "N forks of the same pool" and are allowed to overlap with
556
+ # each other and with named capsules.
557
+ #
558
+ # The wildcard '*' counts as overlapping with EVERY other queue (and
559
+ # vice versa) because at runtime '*' is expanded to all known queues.
560
+ # Raises ArgumentError on overlap.
452
561
  def validate_no_queue_overlap!(new_queues)
453
- existing = (@workers || []).flat_map { |c| c[:queues] || c["queues"] || [] }
454
- return if existing.empty?
562
+ existing_named = (@workers || []).select { |c| capsule_name(c) }
563
+ return if existing_named.empty?
564
+
565
+ existing_queues = existing_named.flat_map { |c| c[:queues] || c["queues"] || [] }
566
+ return if existing_queues.empty?
455
567
 
456
- if existing.include?(CapsuleDSL::WILDCARD)
568
+ if existing_queues.include?(CapsuleDSL::WILDCARD)
457
569
  raise ArgumentError,
458
- "an existing capsule already uses '*' (matches every queue) — " \
570
+ "an existing named capsule already uses '*' (matches every queue) — " \
459
571
  "the new capsule's queues #{new_queues.inspect} would overlap with it"
460
572
  end
461
573
 
462
574
  if new_queues.include?(CapsuleDSL::WILDCARD)
463
575
  raise ArgumentError,
464
- "the new capsule uses '*' (matches every queue) but other capsules " \
465
- "are already defined with queues #{existing.inspect} — " \
576
+ "the new capsule uses '*' (matches every queue) but other named capsules " \
577
+ "are already defined with queues #{existing_queues.inspect} — " \
466
578
  "the wildcard would overlap with all of them"
467
579
  end
468
580
 
469
- conflict = new_queues.find { |q| existing.include?(q) }
581
+ conflict = new_queues.find { |q| existing_queues.include?(q) }
470
582
  return unless conflict
471
583
 
472
584
  raise ArgumentError,
473
- "queue #{conflict.inspect} is already assigned to another capsule — " \
474
- "each queue can only belong to one capsule"
585
+ "queue #{conflict.inspect} is already assigned to another named capsule — " \
586
+ "named capsules cannot share queues"
475
587
  end
476
588
 
477
589
  def sum_thread_counts(entries, default_threads:, group:)
data/lib/pgbus/engine.rb CHANGED
@@ -46,6 +46,7 @@ module Pgbus
46
46
 
47
47
  rake_tasks do
48
48
  load File.expand_path("../tasks/pgbus_pgmq.rake", __dir__)
49
+ load File.expand_path("../tasks/pgbus_streams.rake", __dir__)
49
50
  end
50
51
 
51
52
  initializer "pgbus.i18n" do
@@ -56,5 +57,35 @@ module Pgbus
56
57
  require "pgbus/web/authentication"
57
58
  require "pgbus/web/data_source"
58
59
  end
60
+
61
+ # Install the watermark cache middleware ahead of the app's own
62
+ # middleware so the thread-local cache is cleared between every
63
+ # Rack request. Without this, repeated page renders served by the
64
+ # same Puma thread would see stale current_msg_id values.
65
+ initializer "pgbus.streams.middleware" do |app|
66
+ app.middleware.use Pgbus::Streams::WatermarkCacheMiddleware if Pgbus.configuration.streams_enabled
67
+ end
68
+
69
+ # Install the Turbo::StreamsChannel patch after turbo-rails has been
70
+ # loaded. The patch redirects broadcast_stream_to through Pgbus.stream
71
+ # instead of ActionCable. When turbo-rails is not loaded, this is a
72
+ # no-op and pgbus_stream_from still works via the explicit
73
+ # Pgbus.stream(...).broadcast(...) API.
74
+ initializer "pgbus.streams.turbo_broadcastable", after: :load_config_initializers do
75
+ ActiveSupport.on_load(:after_initialize) do
76
+ if Pgbus.configuration.streams_enabled
77
+ # Touch the constant first so Zeitwerk autoloads
78
+ # lib/pgbus/streams/turbo_broadcastable.rb. The file defines
79
+ # `Pgbus::Streams::TurboBroadcastable` (the autoloaded const)
80
+ # AND `Pgbus::Streams.install_turbo_broadcastable_patch!`
81
+ # (a side-effect class method on the parent module). Without
82
+ # the constant reference, Zeitwerk doesn't load the file and
83
+ # the method call below raises NoMethodError. Assigning to
84
+ # `_` keeps RuboCop's Lint/Void from deleting the line.
85
+ _autoload_trigger = Pgbus::Streams::TurboBroadcastable
86
+ Pgbus::Streams.install_turbo_broadcastable_patch!
87
+ end
88
+ end
89
+ end
59
90
  end
60
91
  end
@@ -33,6 +33,7 @@ module Pgbus
33
33
  @last_batch_cleanup_at = monotonic_now
34
34
  @last_recurring_cleanup_at = monotonic_now
35
35
  @last_archive_compaction_at = monotonic_now
36
+ @last_stream_archive_compaction_at = monotonic_now
36
37
  @last_outbox_cleanup_at = monotonic_now
37
38
  @last_job_lock_cleanup_at = monotonic_now
38
39
  @last_stats_cleanup_at = monotonic_now
@@ -79,6 +80,7 @@ module Pgbus
79
80
  run_if_due(now, :@last_batch_cleanup_at, BATCH_CLEANUP_INTERVAL) { cleanup_batches }
80
81
  run_if_due(now, :@last_recurring_cleanup_at, RECURRING_CLEANUP_INTERVAL) { cleanup_recurring_executions }
81
82
  run_if_due(now, :@last_archive_compaction_at, ARCHIVE_COMPACTION_INTERVAL) { compact_archives }
83
+ run_if_due(now, :@last_stream_archive_compaction_at, ARCHIVE_COMPACTION_INTERVAL) { prune_stream_archives }
82
84
  run_if_due(now, :@last_outbox_cleanup_at, OUTBOX_CLEANUP_INTERVAL) { cleanup_outbox }
83
85
  run_if_due(now, :@last_job_lock_cleanup_at, JOB_LOCK_CLEANUP_INTERVAL) { cleanup_job_locks }
84
86
  run_if_due(now, :@last_stats_cleanup_at, STATS_CLEANUP_INTERVAL) { cleanup_stats }
@@ -140,13 +142,20 @@ module Pgbus
140
142
  end
141
143
 
142
144
  def cleanup_stats
143
- return unless config.stats_enabled
144
-
145
145
  retention = config.stats_retention
146
146
  return unless retention&.positive?
147
147
 
148
- deleted = JobStat.cleanup!(older_than: Time.current - retention)
149
- Pgbus.logger.debug { "[Pgbus] Cleaned up #{deleted} old job stats" } if deleted.positive?
148
+ cutoff = Time.current - retention
149
+
150
+ if config.stats_enabled
151
+ deleted = JobStat.cleanup!(older_than: cutoff)
152
+ Pgbus.logger.debug { "[Pgbus] Cleaned up #{deleted} old job stats" } if deleted.positive?
153
+ end
154
+
155
+ return unless config.streams_stats_enabled
156
+
157
+ deleted = StreamStat.cleanup!(older_than: cutoff)
158
+ Pgbus.logger.debug { "[Pgbus] Cleaned up #{deleted} old stream stats" } if deleted.positive?
150
159
  end
151
160
 
152
161
  def cleanup_job_locks
@@ -241,6 +250,55 @@ module Pgbus
241
250
  Pgbus.logger.warn { "[Pgbus] Archive compaction failed: #{e.message}" }
242
251
  end
243
252
 
253
+ # Prunes per-stream archive tables. Unlike compact_archives (which
254
+ # uses the global archive_retention, typically 7 days), streams need
255
+ # per-stream retention because a chat-history stream and a
256
+ # presence-ping stream have wildly different storage requirements.
257
+ # Lookup order for a given queue: exact string match, then regex
258
+ # match, then streams_default_retention (5 minutes by default).
259
+ def prune_stream_archives
260
+ prefix = config.streams_queue_prefix
261
+ return if prefix.nil? || prefix.empty?
262
+
263
+ batch_size = config.archive_compaction_batch_size || 1000
264
+ conn = config.connects_to ? Pgbus::BusRecord.connection : ActiveRecord::Base.connection
265
+ queue_names = conn.select_values("SELECT queue_name FROM pgmq.meta ORDER BY queue_name")
266
+
267
+ queue_names.each do |full_name|
268
+ next unless full_name.start_with?("#{prefix}_")
269
+
270
+ retention = retention_for_stream_queue(full_name)
271
+ next unless retention.positive?
272
+
273
+ cutoff = Time.current - retention
274
+ stripped = full_name.delete_prefix("#{config.queue_prefix}_")
275
+ deleted = Pgbus.client.purge_archive(stripped, older_than: cutoff, batch_size: batch_size)
276
+ if deleted.positive?
277
+ Pgbus.logger.debug do
278
+ "[Pgbus] Compacted #{deleted} stream archive entries from #{full_name}"
279
+ end
280
+ end
281
+ rescue StandardError => e
282
+ Pgbus.logger.warn { "[Pgbus] Stream archive compaction failed for #{full_name}: #{e.message}" }
283
+ end
284
+ rescue StandardError => e
285
+ Pgbus.logger.warn { "[Pgbus] Stream archive compaction failed: #{e.message}" }
286
+ end
287
+
288
+ def retention_for_stream_queue(full_name)
289
+ retention_map = config.streams_retention || {}
290
+ # Exact-match first — cheapest and most common path
291
+ exact = retention_map[full_name]
292
+ return exact.to_f if exact
293
+
294
+ # Regex-match second for pattern-based overrides (e.g. /^chat_/)
295
+ retention_map.each do |key, value|
296
+ return value.to_f if key.is_a?(Regexp) && key.match?(full_name)
297
+ end
298
+
299
+ config.streams_default_retention.to_f
300
+ end
301
+
244
302
  def cleanup_recurring_executions
245
303
  retention = config.recurring_execution_retention
246
304
  return unless retention&.positive?
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module Streams
5
+ # Parses an SSE replay cursor from a Rack request. The cursor is the highest
6
+ # PGMQ msg_id the client has already seen; the streamer replays everything
7
+ # strictly greater on (re)connect.
8
+ #
9
+ # Two sources, by precedence:
10
+ # 1. Last-Event-ID header (sent automatically by EventSource on reconnect)
11
+ # 2. ?since= query param (sent by the custom element on first connect,
12
+ # because EventSource cannot send custom headers on the initial request)
13
+ #
14
+ # Returns 0 when no cursor is present (i.e. "deliver everything from now").
15
+ module Cursor
16
+ # PGMQ msg_id is BIGINT — strictly within signed 64-bit range.
17
+ MAX_MSG_ID = (2**63) - 1
18
+
19
+ class InvalidCursor < ArgumentError
20
+ end
21
+
22
+ # Strict integer pattern: optional leading minus, then digits. No plus prefix,
23
+ # no decimal, no whitespace. Negatives are caught by validate! with a clearer
24
+ # error message than "must be numeric".
25
+ INTEGER_PATTERN = /\A-?\d+\z/
26
+
27
+ def self.parse(query_since:, last_event_id:)
28
+ raw = pick(last_event_id, query_since)
29
+ return 0 if raw.nil?
30
+
31
+ value = coerce(raw)
32
+ validate!(value)
33
+ value
34
+ end
35
+
36
+ def self.pick(last_event_id, query_since)
37
+ return last_event_id if present?(last_event_id)
38
+ return query_since if present?(query_since)
39
+
40
+ nil
41
+ end
42
+
43
+ def self.present?(value)
44
+ return false if value.nil?
45
+ # Any Integer is "present" so precedence is decided here, not by
46
+ # the sign of the value. validate! rejects negatives separately
47
+ # so a caller passing a literal -1 still crashes loud rather than
48
+ # silently falling through to query_since.
49
+ return true if value.is_a?(Integer)
50
+
51
+ !value.to_s.strip.empty?
52
+ end
53
+
54
+ def self.coerce(raw)
55
+ return raw if raw.is_a?(Integer)
56
+
57
+ str = raw.to_s
58
+ raise InvalidCursor, "cursor must be numeric (got #{raw.inspect})" unless str.match?(INTEGER_PATTERN)
59
+
60
+ Integer(str, 10)
61
+ end
62
+
63
+ def self.validate!(value)
64
+ raise InvalidCursor, "cursor must not be negative (got #{value})" if value.negative?
65
+ raise InvalidCursor, "cursor #{value} is out of BIGINT range" if value > MAX_MSG_ID
66
+ end
67
+
68
+ private_class_method :pick, :present?, :coerce, :validate!
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module Streams
5
+ # Encodes Server-Sent Events frames per https://html.spec.whatwg.org/multipage/server-sent-events.html.
6
+ #
7
+ # Pgbus uses three frame types:
8
+ # - `message(id:, event:, data:)` — a real broadcast (carries an `id:` so the client
9
+ # can resume via `Last-Event-ID` on reconnect)
10
+ # - `comment(text)` — a heartbeat or sentinel that the SSE parser ignores
11
+ # - `retry_directive(ms)` — tells `EventSource` how long to wait before reconnecting
12
+ #
13
+ # All frames end with `\n\n` (the SSE event terminator). `data:` lines must not
14
+ # contain newlines — the SSE spec uses `\n` as the field terminator, so a multi-line
15
+ # payload would arrive as multiple events. We strip `\r` and `\n` from data and
16
+ # comment text rather than splitting into multiple `data:` lines, because Turbo
17
+ # Stream HTML is already flat and the simpler encoding is easier to debug.
18
+ module Envelope
19
+ NEWLINES = /[\r\n]+/
20
+
21
+ RESPONSE_HEADERS = "HTTP/1.1 200 OK\r\n" \
22
+ "content-type: text/event-stream\r\n" \
23
+ "cache-control: no-cache, no-transform\r\n" \
24
+ "x-accel-buffering: no\r\n" \
25
+ "connection: keep-alive\r\n" \
26
+ "\r\n"
27
+
28
+ def self.message(id:, event:, data:)
29
+ raise ArgumentError, "id is required" if id.nil?
30
+ raise ArgumentError, "event is required" if event.nil? || event.to_s.empty?
31
+
32
+ "id: #{id}\nevent: #{event}\ndata: #{strip_newlines(data.to_s)}\n\n"
33
+ end
34
+
35
+ def self.comment(text)
36
+ ": #{strip_newlines(text.to_s)}\n\n"
37
+ end
38
+
39
+ def self.retry_directive(milliseconds)
40
+ unless milliseconds.is_a?(Integer) && !milliseconds.negative?
41
+ raise ArgumentError, "retry must be a non-negative integer (got #{milliseconds.inspect})"
42
+ end
43
+
44
+ "retry: #{milliseconds}\n\n"
45
+ end
46
+
47
+ def self.http_response_headers
48
+ RESPONSE_HEADERS
49
+ end
50
+
51
+ def self.strip_newlines(str)
52
+ str.gsub(NEWLINES, "")
53
+ end
54
+
55
+ private_class_method :strip_newlines
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module Streams
5
+ # Process-wide registry of server-side audience filter predicates.
6
+ # Used by `Pgbus.stream(name).broadcast(html, visible_to: :label)`
7
+ # to restrict delivery to connections whose authorize-hook context
8
+ # matches the predicate.
9
+ #
10
+ # Typical setup at boot time:
11
+ #
12
+ # Pgbus::Streams.filters.register(:admin_only) { |user| user.admin? }
13
+ # Pgbus::Streams.filters.register(:workspace_member) do |user, stream|
14
+ # user.workspaces.pluck(:id).include?(stream.split(":").last.to_i)
15
+ # end
16
+ #
17
+ # Broadcasts reference filters by label:
18
+ #
19
+ # Pgbus.stream("workspace:42").broadcast(html, visible_to: :admin_only)
20
+ #
21
+ # The Dispatcher looks up the filter in the registry, evaluates it
22
+ # against each connection's context (populated from the StreamApp's
23
+ # authorize hook return value), and only delivers to connections
24
+ # where the predicate returns true.
25
+ #
26
+ # Why a registry of labels instead of passing a Proc directly to
27
+ # broadcast: predicates can't be serialized to JSON, so they can't
28
+ # travel through PGMQ. The label is serialized; the predicate lives
29
+ # in-process on the subscriber side. This also means the predicate
30
+ # is evaluated on the same process that holds the SSE connection,
31
+ # so the user context (typically an ActiveRecord model or a session
32
+ # hash) is always available.
33
+ class Filters
34
+ def initialize(logger: nil)
35
+ @mutex = Mutex.new
36
+ @filters = {}
37
+ @logger = logger
38
+ end
39
+
40
+ def register(label, callable = nil, &block)
41
+ raise ArgumentError, "filter label must be a Symbol (got #{label.class})" unless label.is_a?(Symbol)
42
+
43
+ predicate = callable || block
44
+ raise ArgumentError, "filter must be given a block or callable" if predicate.nil?
45
+
46
+ @mutex.synchronize { @filters[label] = predicate }
47
+ end
48
+
49
+ def lookup(label)
50
+ @mutex.synchronize { @filters[label] }
51
+ end
52
+
53
+ # Evaluates the named filter against a context. The context is
54
+ # whatever the StreamApp's authorize hook returned when the
55
+ # connection was established — typically a user model.
56
+ #
57
+ # Policy decisions:
58
+ # - label=nil → visible (no filter attached to the broadcast)
59
+ # - unknown label → NOT visible + warning log. Fail-closed so
60
+ # a typo or renamed filter doesn't turn a restricted
61
+ # broadcast into a public one. The whole point of audience
62
+ # filtering is data isolation; failing open on a typo
63
+ # defeats the feature. The warning log is loud enough that
64
+ # typos still get noticed in dev (check the log or wonder
65
+ # why no subscriber sees your broadcast).
66
+ # - predicate raises → NOT visible (fail-closed on runtime
67
+ # error to avoid leaking data on an exception path).
68
+ def visible?(label, context)
69
+ return true if label.nil?
70
+
71
+ predicate = lookup(label)
72
+ if predicate.nil?
73
+ log_warn("unknown filter label #{label.inspect} — broadcast dropped (fail-closed)")
74
+ return false
75
+ end
76
+
77
+ begin
78
+ !!predicate.call(context)
79
+ rescue StandardError => e
80
+ log_error("filter #{label.inspect} raised #{e.class}: #{e.message} — dropping broadcast (fail-closed)")
81
+ false
82
+ end
83
+ end
84
+
85
+ private
86
+
87
+ def log_warn(message)
88
+ logger = @logger || (Pgbus.logger if defined?(Pgbus) && Pgbus.respond_to?(:logger))
89
+ logger&.warn { "[Pgbus::Streams::Filters] #{message}" }
90
+ end
91
+
92
+ def log_error(message)
93
+ logger = @logger || (Pgbus.logger if defined?(Pgbus) && Pgbus.respond_to?(:logger))
94
+ logger&.error { "[Pgbus::Streams::Filters] #{message}" }
95
+ end
96
+ end
97
+ end
98
+ end