pgbus 0.8.4 → 0.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +16 -0
- data/app/assets/javascripts/pgbus/stream_source_element.js +150 -5
- data/app/controllers/pgbus/batches_controller.rb +15 -0
- data/app/helpers/pgbus/application_helper.rb +12 -0
- data/app/views/layouts/pgbus/application.html.erb +2 -0
- data/app/views/pgbus/batches/_batches_table.html.erb +54 -0
- data/app/views/pgbus/batches/index.html.erb +8 -0
- data/app/views/pgbus/batches/show.html.erb +90 -0
- data/config/locales/da.yml +34 -0
- data/config/locales/de.yml +34 -0
- data/config/locales/en.yml +34 -0
- data/config/locales/es.yml +34 -0
- data/config/locales/fi.yml +34 -0
- data/config/locales/fr.yml +34 -0
- data/config/locales/it.yml +34 -0
- data/config/locales/ja.yml +34 -0
- data/config/locales/nb.yml +34 -0
- data/config/locales/nl.yml +34 -0
- data/config/locales/pt.yml +34 -0
- data/config/locales/sv.yml +34 -0
- data/config/routes.rb +1 -0
- data/lib/pgbus/client.rb +102 -4
- data/lib/pgbus/configuration.rb +84 -0
- data/lib/pgbus/execution_pools/async_pool.rb +44 -6
- data/lib/pgbus/process/supervisor.rb +2 -1
- data/lib/pgbus/process/worker.rb +38 -1
- data/lib/pgbus/serializer.rb +1 -1
- data/lib/pgbus/streams/coalescer.rb +88 -0
- data/lib/pgbus/streams/envelope.rb +20 -1
- data/lib/pgbus/streams/key.rb +35 -2
- data/lib/pgbus/streams/renderer.rb +67 -0
- data/lib/pgbus/streams.rb +150 -1
- data/lib/pgbus/version.rb +1 -1
- data/lib/pgbus/web/data_source.rb +96 -0
- data/lib/pgbus/web/stream_app.rb +8 -4
- data/lib/pgbus/web/streamer/connection.rb +15 -1
- data/lib/pgbus/web/streamer/falcon_connection.rb +9 -1
- data/lib/pgbus/web/streamer/heartbeat.rb +23 -1
- data/lib/pgbus/web/streamer/stream_event_dispatcher.rb +129 -14
- data/lib/pgbus.rb +11 -0
- data/lib/tasks/pgbus_queues.rake +54 -0
- metadata +10 -3
data/lib/pgbus/configuration.rb
CHANGED
|
@@ -44,6 +44,12 @@ module Pgbus
|
|
|
44
44
|
# Priority queues
|
|
45
45
|
attr_accessor :priority_levels, :default_priority
|
|
46
46
|
|
|
47
|
+
# Grouped reads (PGMQ v1.11.0+ FIFO grouping).
|
|
48
|
+
# nil = disabled (default read_batch behavior).
|
|
49
|
+
# :fifo = use read_grouped (drains oldest group first, throughput-optimized).
|
|
50
|
+
# :round_robin = use read_grouped_rr (fair round-robin across groups).
|
|
51
|
+
attr_reader :group_mode
|
|
52
|
+
|
|
47
53
|
# Archive compaction. Only the user-facing retention window is configurable;
|
|
48
54
|
# the loop interval and batch size are tuned via constants on
|
|
49
55
|
# Pgbus::Process::Dispatcher.
|
|
@@ -107,6 +113,7 @@ module Pgbus
|
|
|
107
113
|
:streams_stats_enabled, :streams_test_mode,
|
|
108
114
|
:streams_orphan_sweep_interval, :streams_orphan_threshold,
|
|
109
115
|
:streams_durable_patterns,
|
|
116
|
+
:streams_presence_patterns, :streams_presence_member,
|
|
110
117
|
:streams_host, :streams_port, :streams_database_url
|
|
111
118
|
attr_reader :streams_default_broadcast_mode # rubocop:disable Style/AccessorGrouping
|
|
112
119
|
|
|
@@ -146,6 +153,7 @@ module Pgbus
|
|
|
146
153
|
|
|
147
154
|
@priority_levels = nil
|
|
148
155
|
@default_priority = 1
|
|
156
|
+
@group_mode = nil
|
|
149
157
|
|
|
150
158
|
@archive_retention = 7 * 24 * 3600 # 7 days
|
|
151
159
|
|
|
@@ -240,6 +248,15 @@ module Pgbus
|
|
|
240
248
|
@streams_orphan_sweep_interval = 3600 # 1 hour
|
|
241
249
|
@streams_orphan_threshold = 86_400 # 24 hours
|
|
242
250
|
@streams_durable_patterns = []
|
|
251
|
+
# Streams matching these patterns get connection-driven presence:
|
|
252
|
+
# auto-join on SSE connect, auto-leave on disconnect, touch on the
|
|
253
|
+
# keepalive heartbeat (issue #169). Empty by default (opt-in).
|
|
254
|
+
@streams_presence_patterns = []
|
|
255
|
+
# Extracts a presence member { id:, metadata: } from a connection's
|
|
256
|
+
# authorize-hook context. Defaults to nil, which uses the built-in
|
|
257
|
+
# extractor (see #presence_member_for): a Hash with :member_id/:id,
|
|
258
|
+
# or an object responding to #id.
|
|
259
|
+
@streams_presence_member = nil
|
|
243
260
|
|
|
244
261
|
# AppSignal: auto-on when the appsignal gem is loaded; probe runs in
|
|
245
262
|
# the same process, so the operator can disable it independently.
|
|
@@ -300,6 +317,24 @@ module Pgbus
|
|
|
300
317
|
@streams_default_broadcast_mode = mode
|
|
301
318
|
end
|
|
302
319
|
|
|
320
|
+
VALID_GROUP_MODES = [nil, :fifo, :round_robin].freeze
|
|
321
|
+
|
|
322
|
+
def group_mode=(mode)
|
|
323
|
+
coerced = case mode
|
|
324
|
+
when nil then nil
|
|
325
|
+
when Symbol then mode
|
|
326
|
+
when String then mode.to_sym
|
|
327
|
+
else
|
|
328
|
+
raise ArgumentError,
|
|
329
|
+
"Invalid group_mode type: #{mode.class}. Must be nil, String, or Symbol"
|
|
330
|
+
end
|
|
331
|
+
unless VALID_GROUP_MODES.include?(coerced)
|
|
332
|
+
raise ArgumentError, "Invalid group_mode: #{coerced.inspect}. Must be nil, :fifo, or :round_robin"
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
@group_mode = coerced
|
|
336
|
+
end
|
|
337
|
+
|
|
303
338
|
VALID_PGMQ_SCHEMA_MODES = %i[auto extension embedded].freeze
|
|
304
339
|
|
|
305
340
|
def pgmq_schema_mode=(mode)
|
|
@@ -386,6 +421,12 @@ module Pgbus
|
|
|
386
421
|
|
|
387
422
|
raise ArgumentError, "streams_durable_patterns must be an Array of strings/regex" unless streams_durable_patterns.is_a?(Array)
|
|
388
423
|
|
|
424
|
+
raise ArgumentError, "streams_presence_patterns must be an Array of strings/regex" unless streams_presence_patterns.is_a?(Array)
|
|
425
|
+
|
|
426
|
+
if !streams_presence_member.nil? && !streams_presence_member.respond_to?(:call)
|
|
427
|
+
raise ArgumentError, "streams_presence_member must respond to #call (a Proc/lambda) or be nil"
|
|
428
|
+
end
|
|
429
|
+
|
|
389
430
|
return if streams_orphan_threshold.nil?
|
|
390
431
|
return if streams_orphan_threshold.is_a?(Numeric) && streams_orphan_threshold.positive?
|
|
391
432
|
|
|
@@ -402,6 +443,35 @@ module Pgbus
|
|
|
402
443
|
streams_default_broadcast_mode == :durable
|
|
403
444
|
end
|
|
404
445
|
|
|
446
|
+
# Returns true if the given stream name should have connection-driven
|
|
447
|
+
# presence based on `streams_presence_patterns` (exact string or
|
|
448
|
+
# Regexp match). Presence is opt-in, so the default (no patterns) is
|
|
449
|
+
# false. See issue #169.
|
|
450
|
+
def stream_presence?(name)
|
|
451
|
+
patterns = streams_presence_patterns || []
|
|
452
|
+
patterns.any? { |p| p.is_a?(Regexp) ? p.match?(name) : p == name }
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
# Derives a presence member { id:, metadata: } from a connection's
|
|
456
|
+
# authorize-hook context, or nil when no member can be derived (an
|
|
457
|
+
# anonymous connection — presence is simply skipped). Uses
|
|
458
|
+
# `streams_presence_member` when configured; otherwise the built-in
|
|
459
|
+
# extractor handles the common shapes:
|
|
460
|
+
# - Hash with :member_id (or :id) and optional :metadata
|
|
461
|
+
# - an object responding to #id (e.g. a User model)
|
|
462
|
+
# The id is always coerced to a String and metadata defaults to {}.
|
|
463
|
+
def presence_member_for(context)
|
|
464
|
+
return nil if context.nil?
|
|
465
|
+
|
|
466
|
+
raw = streams_presence_member ? streams_presence_member.call(context) : default_presence_member(context)
|
|
467
|
+
return nil unless raw.is_a?(Hash)
|
|
468
|
+
|
|
469
|
+
id = raw[:id]
|
|
470
|
+
return nil if id.nil? || id.to_s.empty?
|
|
471
|
+
|
|
472
|
+
{ id: id.to_s, metadata: raw[:metadata] || {} }
|
|
473
|
+
end
|
|
474
|
+
|
|
405
475
|
# Set the worker capsule list. Accepts:
|
|
406
476
|
#
|
|
407
477
|
# String — parsed via Pgbus::Configuration::CapsuleDSL into capsules
|
|
@@ -664,6 +734,20 @@ module Pgbus
|
|
|
664
734
|
|
|
665
735
|
private
|
|
666
736
|
|
|
737
|
+
# Built-in presence-member extractor used when no custom
|
|
738
|
+
# `streams_presence_member` is configured. Returns a { id:, metadata: }
|
|
739
|
+
# Hash or nil; #presence_member_for normalizes the id/metadata.
|
|
740
|
+
def default_presence_member(context)
|
|
741
|
+
if context.is_a?(Hash)
|
|
742
|
+
id = context[:member_id] || context[:id]
|
|
743
|
+
return nil if id.nil?
|
|
744
|
+
|
|
745
|
+
{ id: id, metadata: context[:metadata] || {} }
|
|
746
|
+
elsif context.respond_to?(:id)
|
|
747
|
+
{ id: context.id, metadata: {} }
|
|
748
|
+
end
|
|
749
|
+
end
|
|
750
|
+
|
|
667
751
|
# Coerce a duration setting value to a positive Numeric.
|
|
668
752
|
#
|
|
669
753
|
# Accepts an ActiveSupport::Duration (coerced to Integer seconds via .to_i)
|
|
@@ -5,8 +5,6 @@ module Pgbus
|
|
|
5
5
|
class AsyncPool
|
|
6
6
|
attr_reader :capacity
|
|
7
7
|
|
|
8
|
-
IDLE_WAIT_INTERVAL = 0.01
|
|
9
|
-
|
|
10
8
|
def initialize(capacity:, on_state_change: nil)
|
|
11
9
|
@capacity = capacity
|
|
12
10
|
@on_state_change = on_state_change
|
|
@@ -17,6 +15,7 @@ module Pgbus
|
|
|
17
15
|
@fatal_error = nil
|
|
18
16
|
@boot_queue = Thread::Queue.new
|
|
19
17
|
@pending = Thread::Queue.new
|
|
18
|
+
@wake_rd, @wake_wr = IO.pipe
|
|
20
19
|
|
|
21
20
|
validate_dependencies!
|
|
22
21
|
@reactor_thread = start_reactor
|
|
@@ -32,6 +31,7 @@ module Pgbus
|
|
|
32
31
|
reserve_capacity!
|
|
33
32
|
reserved = true
|
|
34
33
|
@pending << block
|
|
34
|
+
wake_reactor
|
|
35
35
|
rescue StandardError
|
|
36
36
|
restore_capacity if reserved
|
|
37
37
|
raise
|
|
@@ -52,6 +52,7 @@ module Pgbus
|
|
|
52
52
|
|
|
53
53
|
@shutdown_flag = true
|
|
54
54
|
end
|
|
55
|
+
wake_reactor
|
|
55
56
|
end
|
|
56
57
|
|
|
57
58
|
def shutdown?
|
|
@@ -96,7 +97,18 @@ module Pgbus
|
|
|
96
97
|
@boot_queue << :ready
|
|
97
98
|
|
|
98
99
|
wait_for_executions(semaphore)
|
|
99
|
-
wait_for_inflight
|
|
100
|
+
wait_for_inflight(task)
|
|
101
|
+
ensure
|
|
102
|
+
begin
|
|
103
|
+
@wake_rd.close
|
|
104
|
+
rescue IOError
|
|
105
|
+
nil
|
|
106
|
+
end
|
|
107
|
+
begin
|
|
108
|
+
@wake_wr.close
|
|
109
|
+
rescue IOError
|
|
110
|
+
nil
|
|
111
|
+
end
|
|
100
112
|
end
|
|
101
113
|
rescue Exception => e
|
|
102
114
|
register_fatal_error(e)
|
|
@@ -110,10 +122,35 @@ module Pgbus
|
|
|
110
122
|
schedule_pending(semaphore)
|
|
111
123
|
break if shutdown? && @pending.empty?
|
|
112
124
|
|
|
113
|
-
|
|
125
|
+
wait_for_wake if @pending.empty?
|
|
114
126
|
end
|
|
115
127
|
end
|
|
116
128
|
|
|
129
|
+
# Fiber-aware wait: yields the reactor fiber until the main thread
|
|
130
|
+
# writes a wake byte via wake_reactor. IO#wait_readable integrates
|
|
131
|
+
# with the Async scheduler so other fibers continue running.
|
|
132
|
+
def wait_for_wake
|
|
133
|
+
return if @wake_rd.closed?
|
|
134
|
+
|
|
135
|
+
@wake_rd.wait_readable
|
|
136
|
+
drain_wake_pipe
|
|
137
|
+
rescue IOError, Errno::EBADF
|
|
138
|
+
nil
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def drain_wake_pipe
|
|
142
|
+
@wake_rd.read_nonblock(256)
|
|
143
|
+
rescue IOError, SystemCallError
|
|
144
|
+
nil
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Thread-safe: called from the main thread to wake the reactor fiber.
|
|
148
|
+
def wake_reactor
|
|
149
|
+
@wake_wr.write_nonblock(".")
|
|
150
|
+
rescue IO::WaitWritable, IOError, Errno::EBADF, Errno::EPIPE
|
|
151
|
+
nil
|
|
152
|
+
end
|
|
153
|
+
|
|
117
154
|
def schedule_pending(semaphore)
|
|
118
155
|
while (block = next_pending)
|
|
119
156
|
semaphore.async do
|
|
@@ -160,6 +197,7 @@ module Pgbus
|
|
|
160
197
|
@available_capacity += 1
|
|
161
198
|
@available_capacity.positive?
|
|
162
199
|
end
|
|
200
|
+
wake_reactor
|
|
163
201
|
@on_state_change&.call if should_notify
|
|
164
202
|
end
|
|
165
203
|
|
|
@@ -174,8 +212,8 @@ module Pgbus
|
|
|
174
212
|
raise error if error
|
|
175
213
|
end
|
|
176
214
|
|
|
177
|
-
def wait_for_inflight
|
|
178
|
-
sleep(
|
|
215
|
+
def wait_for_inflight(task)
|
|
216
|
+
task.sleep(0.01) while inflight?
|
|
179
217
|
end
|
|
180
218
|
|
|
181
219
|
def inflight?
|
|
@@ -69,6 +69,7 @@ module Pgbus
|
|
|
69
69
|
single_active = worker_config[:single_active_consumer] || worker_config["single_active_consumer"] || false
|
|
70
70
|
priority = worker_config[:consumer_priority] || worker_config["consumer_priority"] || 0
|
|
71
71
|
exec_mode = config.execution_mode_for(worker_config)
|
|
72
|
+
grp_mode = worker_config[:group_mode] || worker_config["group_mode"] || config.group_mode
|
|
72
73
|
|
|
73
74
|
pid = fork do
|
|
74
75
|
restore_signals
|
|
@@ -78,7 +79,7 @@ module Pgbus
|
|
|
78
79
|
worker = Worker.new(
|
|
79
80
|
queues: queues, threads: threads, config: config,
|
|
80
81
|
single_active_consumer: single_active, consumer_priority: priority,
|
|
81
|
-
execution_mode: exec_mode
|
|
82
|
+
execution_mode: exec_mode, group_mode: grp_mode
|
|
82
83
|
)
|
|
83
84
|
worker.run
|
|
84
85
|
end
|
data/lib/pgbus/process/worker.rb
CHANGED
|
@@ -11,12 +11,24 @@ module Pgbus
|
|
|
11
11
|
|
|
12
12
|
def initialize(queues:, threads: 5, config: Pgbus.configuration,
|
|
13
13
|
single_active_consumer: false, consumer_priority: 0,
|
|
14
|
-
execution_mode: :threads)
|
|
14
|
+
execution_mode: :threads, group_mode: nil)
|
|
15
15
|
@queues = Array(queues)
|
|
16
16
|
@wildcard = @queues.include?("*")
|
|
17
17
|
@threads = threads
|
|
18
18
|
@config = config
|
|
19
19
|
@execution_mode = ExecutionPools.normalize_mode(execution_mode)
|
|
20
|
+
@group_mode = case group_mode
|
|
21
|
+
when nil then nil
|
|
22
|
+
when Symbol then group_mode
|
|
23
|
+
when String then group_mode.to_sym
|
|
24
|
+
else
|
|
25
|
+
raise ArgumentError,
|
|
26
|
+
"Invalid group_mode type: #{group_mode.class}. Must be nil, String, or Symbol"
|
|
27
|
+
end
|
|
28
|
+
unless Pgbus::Configuration::VALID_GROUP_MODES.include?(@group_mode)
|
|
29
|
+
raise ArgumentError,
|
|
30
|
+
"Invalid group_mode: #{@group_mode.inspect}. Must be nil, :fifo, or :round_robin"
|
|
31
|
+
end
|
|
20
32
|
@single_active_consumer = single_active_consumer
|
|
21
33
|
@consumer_priority = consumer_priority
|
|
22
34
|
@lifecycle = Lifecycle.new
|
|
@@ -141,6 +153,8 @@ module Pgbus
|
|
|
141
153
|
|
|
142
154
|
if priority_enabled?
|
|
143
155
|
fetch_prioritized(active_queues, qty)
|
|
156
|
+
elsif @group_mode
|
|
157
|
+
fetch_grouped(active_queues, qty)
|
|
144
158
|
elsif active_queues.size == 1
|
|
145
159
|
queue = active_queues.first
|
|
146
160
|
messages = Pgbus.client.read_batch(queue, qty: qty) || []
|
|
@@ -210,6 +224,29 @@ module Pgbus
|
|
|
210
224
|
end
|
|
211
225
|
end
|
|
212
226
|
|
|
227
|
+
# Use grouped reads for fair or throughput-optimized multi-tenant processing.
|
|
228
|
+
# Each queue is read independently with the configured group strategy.
|
|
229
|
+
def fetch_grouped(active_queues, qty)
|
|
230
|
+
remaining = qty
|
|
231
|
+
results = []
|
|
232
|
+
|
|
233
|
+
active_queues.each do |queue|
|
|
234
|
+
break if remaining <= 0
|
|
235
|
+
|
|
236
|
+
messages = case @group_mode
|
|
237
|
+
when :round_robin
|
|
238
|
+
Pgbus.client.read_grouped_rr(queue, qty: remaining) || []
|
|
239
|
+
else # :fifo
|
|
240
|
+
Pgbus.client.read_grouped(queue, qty: remaining) || []
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
messages.each { |m| results << [queue, m] }
|
|
244
|
+
remaining -= messages.size
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
results
|
|
248
|
+
end
|
|
249
|
+
|
|
213
250
|
def priority_enabled?
|
|
214
251
|
config.priority_levels && config.priority_levels > 1
|
|
215
252
|
end
|
data/lib/pgbus/serializer.rb
CHANGED
|
@@ -58,7 +58,7 @@ module Pgbus
|
|
|
58
58
|
raise ArgumentError, "Invalid GlobalID: #{gid_string.inspect}" unless gid
|
|
59
59
|
|
|
60
60
|
allowed = Pgbus.configuration.allowed_global_id_models
|
|
61
|
-
if allowed
|
|
61
|
+
if allowed && allowed.empty?
|
|
62
62
|
raise ArgumentError,
|
|
63
63
|
"GlobalID deserialization is disabled (allowed_global_id_models is empty). " \
|
|
64
64
|
"Set to nil to allow all models, or add permitted classes."
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "concurrent"
|
|
4
|
+
|
|
5
|
+
module Pgbus
|
|
6
|
+
module Streams
|
|
7
|
+
# Publish-side coalescing for high-frequency broadcasts (issue #171).
|
|
8
|
+
#
|
|
9
|
+
# A chatty reactive component — a live cursor, a typing indicator, a
|
|
10
|
+
# progress bar — can fan out many small broadcasts per second. For
|
|
11
|
+
# per-keystroke / per-frame updates that's wasteful: every frame
|
|
12
|
+
# becomes a PGMQ insert (or a NOTIFY) and a fan-out to every connection.
|
|
13
|
+
#
|
|
14
|
+
# The Coalescer batches per (stream, target) within a short window and
|
|
15
|
+
# flushes only the *latest* payload, so superseded frames never hit the
|
|
16
|
+
# bus at all. This is last-write-wins and is only safe for idempotent
|
|
17
|
+
# actions (replace / update of a stable target) — which is exactly the
|
|
18
|
+
# high-frequency case. It is strictly opt-in (`coalesce:` on broadcast).
|
|
19
|
+
#
|
|
20
|
+
# Debounce semantics: the FIRST submit for a (stream, target) schedules
|
|
21
|
+
# a flush `window_ms` later; subsequent submits within that window only
|
|
22
|
+
# overwrite the buffered payload. So latency is bounded to one window
|
|
23
|
+
# and a continuous stream of updates can't starve the flush (it is a
|
|
24
|
+
# trailing-edge-with-max-wait debounce, not a resettable one).
|
|
25
|
+
#
|
|
26
|
+
# Thread-safe: many request threads may submit concurrently. The buffer
|
|
27
|
+
# and the per-key pending-flush set are guarded by a single mutex; the
|
|
28
|
+
# flush itself runs off the mutex on the scheduler's thread.
|
|
29
|
+
class Coalescer
|
|
30
|
+
# Default coalescing window when `coalesce: true` is passed without an
|
|
31
|
+
# explicit millisecond value.
|
|
32
|
+
DEFAULT_WINDOW_MS = 50
|
|
33
|
+
|
|
34
|
+
Entry = Struct.new(:payload, :opts)
|
|
35
|
+
|
|
36
|
+
# scheduler: responds to `schedule(delay_seconds) { ... }`. Defaults
|
|
37
|
+
# to a Concurrent::ScheduledTask-backed scheduler.
|
|
38
|
+
# flush: ->(stream_name:, target:, payload:, opts:) called once per
|
|
39
|
+
# window per key with the latest buffered frame.
|
|
40
|
+
def initialize(flush:, scheduler: nil)
|
|
41
|
+
@flush = flush
|
|
42
|
+
@scheduler = scheduler || ScheduledTaskScheduler.new
|
|
43
|
+
@mutex = Mutex.new
|
|
44
|
+
@buffer = {} # key => Entry (latest)
|
|
45
|
+
@pending = {} # key => true while a flush is scheduled
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Buffers a frame for (stream_name, target). Overwrites any frame
|
|
49
|
+
# already buffered for the same key within the current window. The
|
|
50
|
+
# first submit per window schedules the flush.
|
|
51
|
+
def submit(stream_name:, target:, payload:, opts:, window_ms: DEFAULT_WINDOW_MS)
|
|
52
|
+
key = [stream_name, target]
|
|
53
|
+
schedule = false
|
|
54
|
+
|
|
55
|
+
@mutex.synchronize do
|
|
56
|
+
@buffer[key] = Entry.new(payload, opts)
|
|
57
|
+
unless @pending[key]
|
|
58
|
+
@pending[key] = true
|
|
59
|
+
schedule = true
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
@scheduler.schedule(window_ms / 1000.0) { flush_key(key, stream_name, target) } if schedule
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def flush_key(key, stream_name, target)
|
|
69
|
+
entry = @mutex.synchronize do
|
|
70
|
+
@pending.delete(key)
|
|
71
|
+
@buffer.delete(key)
|
|
72
|
+
end
|
|
73
|
+
return unless entry
|
|
74
|
+
|
|
75
|
+
@flush.call(stream_name: stream_name, target: target, payload: entry.payload, opts: entry.opts)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Default scheduler backed by Concurrent::ScheduledTask. Kept as a
|
|
79
|
+
# tiny adapter so the Coalescer can be unit-tested with a synchronous
|
|
80
|
+
# fake scheduler (no real timers, no sleeps).
|
|
81
|
+
class ScheduledTaskScheduler
|
|
82
|
+
def schedule(delay_seconds, &)
|
|
83
|
+
Concurrent::ScheduledTask.execute(delay_seconds, &)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
3
5
|
module Pgbus
|
|
4
6
|
module Streams
|
|
5
7
|
# Encodes Server-Sent Events frames per https://html.spec.whatwg.org/multipage/server-sent-events.html.
|
|
@@ -29,13 +31,30 @@ module Pgbus
|
|
|
29
31
|
raise ArgumentError, "id is required" if id.nil?
|
|
30
32
|
raise ArgumentError, "event is required" if event.nil? || event.to_s.empty?
|
|
31
33
|
|
|
32
|
-
|
|
34
|
+
# Strip newlines from BOTH event and data, not just data: each is
|
|
35
|
+
# interpolated into its own SSE field line, so an unescaped \r/\n in
|
|
36
|
+
# either would terminate the field early and let a crafted value
|
|
37
|
+
# inject extra SSE fields (a forged id:/data:) into the frame.
|
|
38
|
+
"id: #{id}\nevent: #{strip_newlines(event.to_s)}\ndata: #{strip_newlines(data.to_s)}\n\n"
|
|
33
39
|
end
|
|
34
40
|
|
|
35
41
|
def self.comment(text)
|
|
36
42
|
": #{strip_newlines(text.to_s)}\n\n"
|
|
37
43
|
end
|
|
38
44
|
|
|
45
|
+
# Emits a `pgbus:connected` frame carrying the server-minted
|
|
46
|
+
# connection id as JSON. Sent once, right after the open handshake,
|
|
47
|
+
# so the page can read its own connection id and send it back as
|
|
48
|
+
# `X-Pgbus-Connection` on action requests (actor-echo suppression,
|
|
49
|
+
# issue #165). Deliberately omits an `id:` line: this is connection
|
|
50
|
+
# metadata, not a broadcast, and giving it a cursor id would corrupt
|
|
51
|
+
# the client's Last-Event-ID replay position on reconnect.
|
|
52
|
+
def self.connected(id:)
|
|
53
|
+
raise ArgumentError, "id is required" if id.nil? || id.to_s.empty?
|
|
54
|
+
|
|
55
|
+
"event: pgbus:connected\ndata: #{JSON.generate({ connectionId: id.to_s })}\n\n"
|
|
56
|
+
end
|
|
57
|
+
|
|
39
58
|
def self.retry_directive(milliseconds)
|
|
40
59
|
unless milliseconds.is_a?(Integer) && !milliseconds.negative?
|
|
41
60
|
raise ArgumentError, "retry must be a non-negative integer (got #{milliseconds.inspect})"
|
data/lib/pgbus/streams/key.rb
CHANGED
|
@@ -63,9 +63,42 @@ module Pgbus
|
|
|
63
63
|
# `to_stream_key`/`to_gid_param` implementation that forgot to
|
|
64
64
|
# sanitize) raise an ArgumentError at the call site.
|
|
65
65
|
def stream_key(*parts, digest_bits: DEFAULT_DIGEST_BITS)
|
|
66
|
-
|
|
66
|
+
flattened = Array(parts).flatten
|
|
67
|
+
|
|
68
|
+
# Idempotency for an already-built key: a single String argument
|
|
69
|
+
# is treated as a pre-built pgbus stream key and returned
|
|
70
|
+
# unchanged (after the budget check). This lets a consumer hold
|
|
71
|
+
# one `stream_key` value and pass it to both `turbo_stream_from`
|
|
72
|
+
# and the broadcaster without the colon separator guard raising
|
|
73
|
+
# on the second call. The guard only protects against ambiguous
|
|
74
|
+
# *joins* (`stream_key('a:b', :c)` vs `stream_key('a', 'b:c')`),
|
|
75
|
+
# and there is no second fragment here to collapse against, so the
|
|
76
|
+
# hazard cannot arise. Symbols and records are NOT keys — a colon
|
|
77
|
+
# in those never came from `stream_key` and stays a mistake.
|
|
78
|
+
return stream_key!(flattened.first) if flattened.length == 1 && flattened.first.is_a?(String)
|
|
79
|
+
|
|
80
|
+
fragments = flattened.map { |part| normalize(part, digest_bits: digest_bits) }
|
|
67
81
|
fragments.each { |fragment| reject_colons!(fragment) }
|
|
68
|
-
|
|
82
|
+
validate_budget!(fragments.join(":"))
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Accepts an already-built stream key verbatim, skipping the
|
|
86
|
+
# per-fragment colon guard (a pre-built key legitimately contains
|
|
87
|
+
# ':' separators). Still enforces the queue-name budget so an
|
|
88
|
+
# oversized key fails at the call site rather than deep inside
|
|
89
|
+
# Client#ensure_stream_queue. Use this when you hold a key string
|
|
90
|
+
# and want to be explicit that no re-keying should happen — e.g.
|
|
91
|
+
# passing the same value to `turbo_stream_from` and a broadcaster.
|
|
92
|
+
def stream_key!(key)
|
|
93
|
+
raise ArgumentError, "stream_key! key must be a String, got #{key.class}" unless key.is_a?(String)
|
|
94
|
+
|
|
95
|
+
validate_budget!(key)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Returns the key when it fits the pgbus queue-name budget; raises
|
|
99
|
+
# ArgumentError with an actionable message otherwise. Shared by
|
|
100
|
+
# `stream_key` and `stream_key!` so both paths fail identically.
|
|
101
|
+
def validate_budget!(key)
|
|
69
102
|
budget = queue_name_budget
|
|
70
103
|
return key if key.length <= budget
|
|
71
104
|
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cgi"
|
|
4
|
+
|
|
5
|
+
module Pgbus
|
|
6
|
+
module Streams
|
|
7
|
+
# Turns a renderable into a complete `<turbo-stream>` action tag,
|
|
8
|
+
# ready to hand to Stream#broadcast. This centralises the off-request
|
|
9
|
+
# render + tag-building that every consumer would otherwise stitch
|
|
10
|
+
# together by hand (the #1 footgun in server-driven UI: rendering a
|
|
11
|
+
# component outside a request without a usable view context).
|
|
12
|
+
#
|
|
13
|
+
# pgbus deliberately has no hard dependency on turbo-rails, ActionView,
|
|
14
|
+
# or Phlex, so this builder is self-contained and matches Turbo's wire
|
|
15
|
+
# format directly. The browser's Turbo runtime consumes the tag; the
|
|
16
|
+
# exact string is the contract, not any particular Ruby library.
|
|
17
|
+
#
|
|
18
|
+
# Renderable resolution (first match wins):
|
|
19
|
+
# - String → used verbatim (already-rendered markup)
|
|
20
|
+
# - responds to :call → Phlex::HTML#call (the issue's example shape)
|
|
21
|
+
# - responds to :render_in → ViewComponent / phlex-rails
|
|
22
|
+
# (`render_in(view_context)`; a nil context is passed because
|
|
23
|
+
# off-request there is no controller — components that need URL
|
|
24
|
+
# helpers should be rendered by the app and the string passed in)
|
|
25
|
+
# - else → to_s
|
|
26
|
+
#
|
|
27
|
+
# Tag format mirrors Turbo::Streams::TagBuilder:
|
|
28
|
+
# - content actions wrap the markup in a <template>
|
|
29
|
+
# - content-less actions (remove) emit no <template>
|
|
30
|
+
module Renderer
|
|
31
|
+
# Turbo stream actions that carry no content (no <template> wrapper).
|
|
32
|
+
CONTENTLESS_ACTIONS = %w[remove].freeze
|
|
33
|
+
|
|
34
|
+
module_function
|
|
35
|
+
|
|
36
|
+
# Builds a `<turbo-stream action target><template>...</template></turbo-stream>`
|
|
37
|
+
# string. `renderable` may be nil for content-less actions.
|
|
38
|
+
def turbo_stream_tag(action:, target:, renderable: nil)
|
|
39
|
+
raise ArgumentError, "target is required" if target.nil? || target.to_s.empty?
|
|
40
|
+
|
|
41
|
+
action = action.to_s
|
|
42
|
+
attrs = %(action="#{escape(action)}" target="#{escape(target)}")
|
|
43
|
+
|
|
44
|
+
return "<turbo-stream #{attrs}></turbo-stream>" if CONTENTLESS_ACTIONS.include?(action)
|
|
45
|
+
|
|
46
|
+
content = render(renderable)
|
|
47
|
+
"<turbo-stream #{attrs}><template>#{content}</template></turbo-stream>"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Resolves a renderable to an HTML string. See module docs for the
|
|
51
|
+
# resolution order. Returns "" for nil (a content action with no
|
|
52
|
+
# renderable still emits an empty <template>).
|
|
53
|
+
def render(renderable)
|
|
54
|
+
return "" if renderable.nil?
|
|
55
|
+
return renderable if renderable.is_a?(String)
|
|
56
|
+
return renderable.call.to_s if renderable.respond_to?(:call)
|
|
57
|
+
return renderable.render_in(nil).to_s if renderable.respond_to?(:render_in)
|
|
58
|
+
|
|
59
|
+
renderable.to_s
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def escape(value)
|
|
63
|
+
CGI.escape_html(value.to_s)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|