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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +15 -0
- data/README.md +238 -0
- data/Rakefile +8 -1
- data/app/controllers/pgbus/insights_controller.rb +6 -0
- data/app/helpers/pgbus/streams_helper.rb +115 -0
- data/app/javascript/pgbus/stream_source_element.js +212 -0
- data/app/models/pgbus/stream_stat.rb +118 -0
- data/app/views/pgbus/insights/show.html.erb +59 -0
- data/config/locales/en.yml +16 -0
- data/config/routes.rb +11 -0
- data/lib/generators/pgbus/add_presence_generator.rb +55 -0
- data/lib/generators/pgbus/add_stream_stats_generator.rb +54 -0
- data/lib/generators/pgbus/templates/add_presence.rb.erb +26 -0
- data/lib/generators/pgbus/templates/add_stream_stats.rb.erb +18 -0
- data/lib/pgbus/client/ensure_stream_queue.rb +54 -0
- data/lib/pgbus/client/read_after.rb +100 -0
- data/lib/pgbus/client.rb +6 -0
- data/lib/pgbus/configuration/capsule_dsl.rb +6 -20
- data/lib/pgbus/configuration.rb +126 -14
- data/lib/pgbus/engine.rb +31 -0
- data/lib/pgbus/process/dispatcher.rb +62 -4
- data/lib/pgbus/streams/cursor.rb +71 -0
- data/lib/pgbus/streams/envelope.rb +58 -0
- data/lib/pgbus/streams/filters.rb +98 -0
- data/lib/pgbus/streams/presence.rb +216 -0
- data/lib/pgbus/streams/signed_name.rb +69 -0
- data/lib/pgbus/streams/turbo_broadcastable.rb +53 -0
- data/lib/pgbus/streams/watermark_cache_middleware.rb +28 -0
- data/lib/pgbus/streams.rb +151 -0
- data/lib/pgbus/version.rb +1 -1
- data/lib/pgbus/web/data_source.rb +29 -0
- data/lib/pgbus/web/stream_app.rb +179 -0
- data/lib/pgbus/web/streamer/connection.rb +122 -0
- data/lib/pgbus/web/streamer/dispatcher.rb +467 -0
- data/lib/pgbus/web/streamer/heartbeat.rb +105 -0
- data/lib/pgbus/web/streamer/instance.rb +176 -0
- data/lib/pgbus/web/streamer/io_writer.rb +73 -0
- data/lib/pgbus/web/streamer/listener.rb +228 -0
- data/lib/pgbus/web/streamer/registry.rb +103 -0
- data/lib/pgbus/web/streamer.rb +53 -0
- data/lib/pgbus.rb +28 -0
- data/lib/puma/plugin/pgbus_streams.rb +54 -0
- data/lib/tasks/pgbus_streams.rake +52 -0
- metadata +29 -1
data/lib/pgbus/configuration.rb
CHANGED
|
@@ -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)
|
|
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
|
-
#
|
|
449
|
-
#
|
|
450
|
-
#
|
|
451
|
-
#
|
|
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
|
-
|
|
454
|
-
return if
|
|
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
|
|
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 #{
|
|
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|
|
|
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
|
-
"
|
|
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
|
-
|
|
149
|
-
|
|
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
|