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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +16 -0
  3. data/app/assets/javascripts/pgbus/stream_source_element.js +150 -5
  4. data/app/controllers/pgbus/batches_controller.rb +15 -0
  5. data/app/helpers/pgbus/application_helper.rb +12 -0
  6. data/app/views/layouts/pgbus/application.html.erb +2 -0
  7. data/app/views/pgbus/batches/_batches_table.html.erb +54 -0
  8. data/app/views/pgbus/batches/index.html.erb +8 -0
  9. data/app/views/pgbus/batches/show.html.erb +90 -0
  10. data/config/locales/da.yml +34 -0
  11. data/config/locales/de.yml +34 -0
  12. data/config/locales/en.yml +34 -0
  13. data/config/locales/es.yml +34 -0
  14. data/config/locales/fi.yml +34 -0
  15. data/config/locales/fr.yml +34 -0
  16. data/config/locales/it.yml +34 -0
  17. data/config/locales/ja.yml +34 -0
  18. data/config/locales/nb.yml +34 -0
  19. data/config/locales/nl.yml +34 -0
  20. data/config/locales/pt.yml +34 -0
  21. data/config/locales/sv.yml +34 -0
  22. data/config/routes.rb +1 -0
  23. data/lib/pgbus/client.rb +102 -4
  24. data/lib/pgbus/configuration.rb +84 -0
  25. data/lib/pgbus/execution_pools/async_pool.rb +44 -6
  26. data/lib/pgbus/process/supervisor.rb +2 -1
  27. data/lib/pgbus/process/worker.rb +38 -1
  28. data/lib/pgbus/serializer.rb +1 -1
  29. data/lib/pgbus/streams/coalescer.rb +88 -0
  30. data/lib/pgbus/streams/envelope.rb +20 -1
  31. data/lib/pgbus/streams/key.rb +35 -2
  32. data/lib/pgbus/streams/renderer.rb +67 -0
  33. data/lib/pgbus/streams.rb +150 -1
  34. data/lib/pgbus/version.rb +1 -1
  35. data/lib/pgbus/web/data_source.rb +96 -0
  36. data/lib/pgbus/web/stream_app.rb +8 -4
  37. data/lib/pgbus/web/streamer/connection.rb +15 -1
  38. data/lib/pgbus/web/streamer/falcon_connection.rb +9 -1
  39. data/lib/pgbus/web/streamer/heartbeat.rb +23 -1
  40. data/lib/pgbus/web/streamer/stream_event_dispatcher.rb +129 -14
  41. data/lib/pgbus.rb +11 -0
  42. data/lib/tasks/pgbus_queues.rake +54 -0
  43. metadata +10 -3
@@ -32,18 +32,34 @@ module Pgbus
32
32
  # disambiguate from Pgbus::Process::Dispatcher, which is an
33
33
  # unrelated worker-side pool coordinator. See issue #98 item 8.
34
34
  class StreamEventDispatcher
35
- WakeMessage = Listener::WakeMessage
36
- ConnectMessage = Data.define(:connection)
37
- DisconnectMessage = Data.define(:connection)
35
+ WakeMessage = Listener::WakeMessage
36
+ ConnectMessage = Data.define(:connection)
37
+ DisconnectMessage = Data.define(:connection)
38
+ # Posted by the Heartbeat once per tick with the current presence
39
+ # connections, so the touch (a last_seen_at refresh) runs on the
40
+ # dispatcher thread where AR connections are released each pass.
41
+ PresenceTouchMessage = Data.define(:connections)
38
42
 
39
43
  # An unwrapped stream broadcast. Similar shape to
40
44
  # Pgbus::Client::ReadAfter::Envelope (msg_id + payload) so
41
45
  # Connection#enqueue can consume either type via duck typing,
42
- # but adds the `visible_to` label carried through from
43
- # Pgbus::Streams::Stream#broadcast. The Dispatcher uses
44
- # visible_to to decide per-connection delivery; Connection
45
- # never sees the field.
46
- StreamEnvelope = Data.define(:msg_id, :enqueued_at, :payload, :source, :visible_to)
46
+ # but adds two delivery-control fields carried through from
47
+ # Pgbus::Streams::Stream#broadcast:
48
+ # - `visible_to` audience filter label (evaluated per-connection)
49
+ # - `exclude` — a connection id to skip (actor-echo suppression:
50
+ # the broadcaster's own SSE connection does not
51
+ # receive the echo of its own broadcast)
52
+ # The Dispatcher uses both to decide per-connection delivery;
53
+ # Connection never sees either field.
54
+ # - `event` — the SSE `event:` name for the delivered frame.
55
+ # nil means the default (turbo-stream); a typed
56
+ # name (e.g. "presence", "reactive") lets clients
57
+ # route without sniffing the HTML (issue #170).
58
+ StreamEnvelope = Data.define(:msg_id, :enqueued_at, :payload, :source, :visible_to, :exclude, :event) do
59
+ def initialize(msg_id:, enqueued_at:, payload:, source:, visible_to: nil, exclude: nil, event: nil)
60
+ super
61
+ end
62
+ end
47
63
 
48
64
  DEFAULT_READ_LIMIT = 500
49
65
 
@@ -51,13 +67,18 @@ module Pgbus
51
67
 
52
68
  def initialize(client:, registry:, listener:, dispatch_queue:,
53
69
  logger: Pgbus.logger, read_limit: DEFAULT_READ_LIMIT,
54
- filters: nil, config: nil, stream_counter: nil)
70
+ filters: nil, config: nil, stream_counter: nil,
71
+ presence_provider: nil)
55
72
  @client = client
56
73
  @registry = registry
57
74
  @listener = listener
58
75
  @queue = dispatch_queue
59
76
  @logger = logger
60
77
  @read_limit = read_limit
78
+ # Vends a presence handle for a logical stream name. Injected so
79
+ # tests can record join/leave/touch without a DB. Production
80
+ # defaults to the real per-stream Presence via Pgbus.stream.
81
+ @presence_provider = presence_provider || ->(name) { Pgbus.stream(name).presence }
61
82
  # Filters default to the process-wide registry so production
62
83
  # code picks up whatever was registered at boot. Tests inject
63
84
  # a fresh Filters instance to avoid cross-test pollution.
@@ -175,9 +196,10 @@ module Pgbus
175
196
 
176
197
  def handle(msg)
177
198
  case msg
178
- when WakeMessage then handle_wake(msg)
179
- when ConnectMessage then handle_connect(msg)
180
- when DisconnectMessage then handle_disconnect(msg)
199
+ when WakeMessage then handle_wake(msg)
200
+ when ConnectMessage then handle_connect(msg)
201
+ when DisconnectMessage then handle_disconnect(msg)
202
+ when PresenceTouchMessage then handle_presence_touch(msg)
181
203
  else
182
204
  @logger.warn { "[Pgbus::Streamer::StreamEventDispatcher] unknown message: #{msg.class}" }
183
205
  end
@@ -239,6 +261,7 @@ module Pgbus
239
261
 
240
262
  visible_to = parsed["visible_to"]
241
263
  visible_to = visible_to.to_sym if visible_to.is_a?(String)
264
+ exclude = parsed["exclude"]
242
265
 
243
266
  @ephemeral_seq += 1
244
267
  envelope = StreamEnvelope.new(
@@ -246,7 +269,9 @@ module Pgbus
246
269
  enqueued_at: Time.now.utc.iso8601(6),
247
270
  payload: html,
248
271
  source: "ephemeral",
249
- visible_to: visible_to
272
+ visible_to: visible_to,
273
+ exclude: exclude,
274
+ event: normalize_sse_event(parsed["event"])
250
275
  )
251
276
 
252
277
  registered.each do |conn|
@@ -320,6 +345,7 @@ module Pgbus
320
345
  else
321
346
  @stream_counter.increment_connections(stream)
322
347
  @registry.register(connection)
348
+ presence_join(connection, stream)
323
349
  end
324
350
 
325
351
  record_stat(
@@ -346,6 +372,7 @@ module Pgbus
346
372
  removed = @registry.unregister(connection)
347
373
  @scanned_cursor.delete(connection)
348
374
  @stream_counter.decrement_connections(stream) if removed
375
+ presence_leave(connection, stream)
349
376
  cleanup_stream_if_unused(stream)
350
377
 
351
378
  record_stat(
@@ -355,6 +382,21 @@ module Pgbus
355
382
  )
356
383
  end
357
384
 
385
+ # Touches (refreshes last_seen_at for) the presence members on the
386
+ # given connections. Posted by the Heartbeat each tick so idle but
387
+ # still-connected members don't get swept. Connections without a
388
+ # presence member (non-presence streams, anonymous) are skipped.
389
+ def handle_presence_touch(msg)
390
+ msg.connections.each do |connection|
391
+ member_id = presence_member_of(connection)
392
+ next unless member_id
393
+
394
+ @presence_provider.call(connection.stream_name).touch(member_id: member_id)
395
+ rescue StandardError => e
396
+ @logger.error { "[Pgbus::Streamer::StreamEventDispatcher] presence touch failed: #{e.class}: #{e.message}" }
397
+ end
398
+ end
399
+
358
400
  # If this stream has no remaining subscribers (registered or
359
401
  # in-flight), release all per-stream state so long-running
360
402
  # processes don't leak memory proportional to unique stream
@@ -426,6 +468,22 @@ module Pgbus
426
468
  @client.config.queue_name(stream_name)
427
469
  end
428
470
 
471
+ # Sanitizes a typed SSE event name from an untrusted broadcast
472
+ # payload before it reaches the SSE `event:` line. Returns nil
473
+ # (→ the default turbo-stream event) for non-strings, blanks, or
474
+ # any value containing CR/LF — a crafted event with a newline could
475
+ # otherwise inject extra SSE fields (a forged id:/data:) into the
476
+ # frame and corrupt cursor/event routing. Defense in depth with
477
+ # Envelope.message, which also strips newlines.
478
+ def normalize_sse_event(value)
479
+ return nil unless value.is_a?(String)
480
+
481
+ event = value.strip
482
+ return nil if event.empty? || event.match?(/[\r\n]/)
483
+
484
+ event
485
+ end
486
+
429
487
  # Pgbus::Streams::Stream#broadcast wraps HTML payloads as
430
488
  # {"html": "..."} so PGMQ's JSONB column accepts them. Here we
431
489
  # unwrap the html field and return a new envelope whose payload
@@ -442,13 +500,16 @@ module Pgbus
442
500
 
443
501
  visible_to = parsed["visible_to"]
444
502
  visible_to = visible_to.to_sym if visible_to.is_a?(String)
503
+ exclude = parsed["exclude"]
445
504
 
446
505
  StreamEnvelope.new(
447
506
  msg_id: envelope.msg_id,
448
507
  enqueued_at: envelope.enqueued_at,
449
508
  payload: html,
450
509
  source: envelope.source,
451
- visible_to: visible_to
510
+ visible_to: visible_to,
511
+ exclude: exclude,
512
+ event: normalize_sse_event(parsed["event"])
452
513
  )
453
514
  rescue JSON::ParserError
454
515
  envelope
@@ -460,13 +521,67 @@ module Pgbus
460
521
  # Filters registry. Envelopes that predate the StreamEnvelope
461
522
  # refactor (plain ReadAfter::Envelope with no visible_to) also
462
523
  # pass through.
524
+ #
525
+ # Actor-echo suppression: an envelope carrying `exclude:` (a
526
+ # connection id) is dropped for the connection whose id matches.
527
+ # This lets the broadcaster's own SSE connection skip the echo of
528
+ # its own broadcast — the actor already applied the change via its
529
+ # action's HTTP response, so re-applying the SSE echo would
530
+ # double-apply (re-run animations, clobber optimistic edits). The
531
+ # exclude check runs *before* the audience filter so an excluded
532
+ # actor is skipped even when it would otherwise match visible_to.
463
533
  def visible_envelopes_for(envelopes, connection)
464
534
  envelopes.select do |envelope|
535
+ next false if excluded?(envelope, connection)
536
+
465
537
  label = envelope.respond_to?(:visible_to) ? envelope.visible_to : nil
466
538
  @filters.visible?(label, connection.context)
467
539
  end
468
540
  end
469
541
 
542
+ # True when the envelope names this connection in its `exclude`
543
+ # field. Guarded by respond_to? so plain ReadAfter::Envelopes
544
+ # (no exclude field) and connections without an id never match.
545
+ def excluded?(envelope, connection)
546
+ return false unless envelope.respond_to?(:exclude)
547
+
548
+ exclude = envelope.exclude
549
+ return false if exclude.nil? || exclude.to_s.empty?
550
+
551
+ connection.respond_to?(:id) && connection.id.to_s == exclude.to_s
552
+ end
553
+
554
+ # Connection-driven presence (issue #169). Auto-joins a member when
555
+ # the stream is configured for presence and the connection's
556
+ # authorize-context yields a member id. Stores the member id on the
557
+ # connection so handle_disconnect/handle_presence_touch can act on
558
+ # it. Failures are logged and swallowed: a presence DB hiccup must
559
+ # not knock a live SSE connection out of the registry.
560
+ def presence_join(connection, stream)
561
+ return unless @config&.stream_presence?(stream)
562
+
563
+ member = @config.presence_member_for(connection.context)
564
+ return unless member
565
+
566
+ @presence_provider.call(stream).join(member_id: member[:id], metadata: member[:metadata] || {})
567
+ connection.presence_member = member[:id] if connection.respond_to?(:presence_member=)
568
+ rescue StandardError => e
569
+ @logger.error { "[Pgbus::Streamer::StreamEventDispatcher] presence join failed: #{e.class}: #{e.message}" }
570
+ end
571
+
572
+ def presence_leave(connection, stream)
573
+ member_id = presence_member_of(connection)
574
+ return unless member_id
575
+
576
+ @presence_provider.call(stream).leave(member_id: member_id)
577
+ rescue StandardError => e
578
+ @logger.error { "[Pgbus::Streamer::StreamEventDispatcher] presence leave failed: #{e.class}: #{e.message}" }
579
+ end
580
+
581
+ def presence_member_of(connection)
582
+ connection.respond_to?(:presence_member) ? connection.presence_member : nil
583
+ end
584
+
470
585
  def monotonic_ms
471
586
  ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) * 1000.0
472
587
  end
data/lib/pgbus.rb CHANGED
@@ -130,6 +130,17 @@ module Pgbus
130
130
  Streams::Key.stream_key(*parts, **)
131
131
  end
132
132
 
133
+ # Accepts an already-built stream key verbatim (no re-keying), only
134
+ # enforcing the queue-name budget. Use when you hold a key string and
135
+ # want to pass it to both `turbo_stream_from` and a broadcaster
136
+ # without the colon-separator guard raising on the second call.
137
+ #
138
+ # key = Pgbus.stream_key(chat, :messages)
139
+ # Pgbus.stream(Pgbus.stream_key!(key)).broadcast(html)
140
+ def stream_key!(key)
141
+ Streams::Key.stream_key!(key)
142
+ end
143
+
133
144
  def reset!
134
145
  @client&.close
135
146
  @client = nil
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :pgbus do
4
+ namespace :queues do
5
+ desc "Create FIFO indexes on all pgbus queues (required for grouped reads)"
6
+ task fifo_indexes: :environment do
7
+ puts "Creating FIFO indexes on all pgbus queues..."
8
+ Pgbus.client.create_fifo_indexes_all
9
+ puts "Done. FIFO indexes created on all queues."
10
+ end
11
+
12
+ desc "Create FIFO index on a specific queue (QUEUE=name)"
13
+ task fifo_index: :environment do
14
+ queue = ENV.fetch("QUEUE") do
15
+ abort "Usage: rake pgbus:queues:fifo_index QUEUE=<queue_name>"
16
+ end
17
+ puts "Creating FIFO index on queue '#{queue}'..."
18
+ Pgbus.client.create_fifo_index(queue)
19
+ puts "Done."
20
+ end
21
+ end
22
+
23
+ namespace :archives do
24
+ desc "Convert a queue's archive table to pg_partman-managed partitions (QUEUE=name)"
25
+ task partition: :environment do
26
+ queue = ENV.fetch("QUEUE") do
27
+ abort "Usage: rake pgbus:archives:partition QUEUE=<queue_name> " \
28
+ "[INTERVAL=10000] [RETENTION=100000] [LEADING_PARTITION=10]"
29
+ end
30
+ interval = ENV.fetch("INTERVAL", "10000")
31
+ retention = ENV.fetch("RETENTION", "100000")
32
+ leading_raw = ENV.fetch("LEADING_PARTITION", "10")
33
+ leading = begin
34
+ Integer(leading_raw, 10)
35
+ rescue ArgumentError, TypeError
36
+ abort "LEADING_PARTITION must be a positive integer, got #{leading_raw.inspect}"
37
+ end
38
+ abort "LEADING_PARTITION must be a positive integer" if leading <= 0
39
+
40
+ puts "Converting archive table for queue '#{queue}' to partitioned..."
41
+ puts " Partition interval: #{interval}"
42
+ puts " Retention interval: #{retention}"
43
+ puts " Leading partitions: #{leading}"
44
+
45
+ Pgbus.client.convert_archive_partitioned(
46
+ queue,
47
+ partition_interval: interval,
48
+ retention_interval: retention,
49
+ leading_partition: leading
50
+ )
51
+ puts "Done. Archive table is now partitioned."
52
+ end
53
+ end
54
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pgbus
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.4
4
+ version: 0.9.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson
@@ -63,14 +63,14 @@ dependencies:
63
63
  requirements:
64
64
  - - "~>"
65
65
  - !ruby/object:Gem::Version
66
- version: '0.5'
66
+ version: 0.7.0
67
67
  type: :runtime
68
68
  prerelease: false
69
69
  version_requirements: !ruby/object:Gem::Requirement
70
70
  requirements:
71
71
  - - "~>"
72
72
  - !ruby/object:Gem::Version
73
- version: '0.5'
73
+ version: 0.7.0
74
74
  - !ruby/object:Gem::Dependency
75
75
  name: railties
76
76
  requirement: !ruby/object:Gem::Requirement
@@ -126,6 +126,7 @@ files:
126
126
  - app/controllers/pgbus/api/metrics_controller.rb
127
127
  - app/controllers/pgbus/api/stats_controller.rb
128
128
  - app/controllers/pgbus/application_controller.rb
129
+ - app/controllers/pgbus/batches_controller.rb
129
130
  - app/controllers/pgbus/dashboard_controller.rb
130
131
  - app/controllers/pgbus/dead_letter_controller.rb
131
132
  - app/controllers/pgbus/events_controller.rb
@@ -161,6 +162,9 @@ files:
161
162
  - app/models/pgbus/stream_stat.rb
162
163
  - app/models/pgbus/uniqueness_key.rb
163
164
  - app/views/layouts/pgbus/application.html.erb
165
+ - app/views/pgbus/batches/_batches_table.html.erb
166
+ - app/views/pgbus/batches/index.html.erb
167
+ - app/views/pgbus/batches/show.html.erb
164
168
  - app/views/pgbus/dashboard/_processes_table.html.erb
165
169
  - app/views/pgbus/dashboard/_queue_health.html.erb
166
170
  - app/views/pgbus/dashboard/_queues_table.html.erb
@@ -309,11 +313,13 @@ files:
309
313
  - lib/pgbus/stat_buffer.rb
310
314
  - lib/pgbus/streams.rb
311
315
  - lib/pgbus/streams/broadcastable_override.rb
316
+ - lib/pgbus/streams/coalescer.rb
312
317
  - lib/pgbus/streams/cursor.rb
313
318
  - lib/pgbus/streams/envelope.rb
314
319
  - lib/pgbus/streams/filters.rb
315
320
  - lib/pgbus/streams/key.rb
316
321
  - lib/pgbus/streams/presence.rb
322
+ - lib/pgbus/streams/renderer.rb
317
323
  - lib/pgbus/streams/signed_name.rb
318
324
  - lib/pgbus/streams/streamable.rb
319
325
  - lib/pgbus/streams/turbo_broadcastable.rb
@@ -343,6 +349,7 @@ files:
343
349
  - lib/puma/plugin/pgbus_streams.rb
344
350
  - lib/tasks/pgbus_autovacuum.rake
345
351
  - lib/tasks/pgbus_pgmq.rake
352
+ - lib/tasks/pgbus_queues.rake
346
353
  - lib/tasks/pgbus_streams.rake
347
354
  homepage: https://github.com/mhenrixon/pgbus
348
355
  licenses: