pgbus 0.17.2 → 0.17.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +4 -0
  3. data/README.md +35 -0
  4. data/Rakefile +26 -0
  5. data/app/assets/javascripts/pgbus/stream_source_element.js +34 -2
  6. data/app/assets/javascripts/pgbus/stream_source_element.min.js +6 -0
  7. data/app/assets/javascripts/pgbus/stream_source_element.min.js.map +1 -0
  8. data/app/controllers/pgbus/frontends_controller.rb +25 -11
  9. data/app/frontend/pgbus/application.min.js +2 -0
  10. data/app/frontend/pgbus/application.min.js.map +1 -0
  11. data/app/frontend/pgbus/modules/charts.js +24 -6
  12. data/app/frontend/pgbus/modules/charts.min.js +2 -0
  13. data/app/frontend/pgbus/modules/charts.min.js.map +1 -0
  14. data/app/frontend/pgbus/vendor/turbo.min.js +54 -0
  15. data/app/frontend/pgbus/vendor/turbo.min.js.map +1 -0
  16. data/app/views/layouts/pgbus/application.html.erb +0 -1
  17. data/config/routes.rb +1 -1
  18. data/lib/generators/pgbus/add_presence_generator.rb +2 -1
  19. data/lib/generators/pgbus/add_presence_seats_generator.rb +45 -0
  20. data/lib/generators/pgbus/templates/add_presence.rb.erb +32 -7
  21. data/lib/generators/pgbus/templates/add_presence_seats.rb.erb +38 -0
  22. data/lib/pgbus/configuration/presence_settings.rb +103 -0
  23. data/lib/pgbus/configuration.rb +4 -0
  24. data/lib/pgbus/engine.rb +13 -7
  25. data/lib/pgbus/generators/migration_detector.rb +9 -1
  26. data/lib/pgbus/streams/presence.rb +317 -81
  27. data/lib/pgbus/version.rb +1 -1
  28. data/lib/pgbus/web/stream_app.rb +18 -6
  29. data/lib/pgbus/web/streamer/connection.rb +7 -1
  30. data/lib/pgbus/web/streamer/falcon_connection.rb +7 -1
  31. data/lib/pgbus/web/streamer/heartbeat.rb +9 -3
  32. data/lib/pgbus/web/streamer/instance.rb +2 -1
  33. data/lib/pgbus/web/streamer/presence_tracker.rb +155 -0
  34. data/lib/pgbus/web/streamer/stream_event_dispatcher.rb +8 -37
  35. metadata +13 -1
@@ -86,7 +86,6 @@
86
86
  <%# Importmap for ES modules — keys are internal symbols, values are app-controlled paths, no user input %>
87
87
  <% importmaps = Pgbus::FrontendsController.js_modules.keys.index_with { |mod| frontend_module_path(mod, format: :js, locale: nil) } %>
88
88
  <%= tag.script({ imports: importmaps }.to_json.html_safe, type: "importmap", nonce: content_security_policy_nonce) %> <%# herb:disable erb-no-unsafe-raw %>
89
- <%= tag.script("", src: frontend_static_path(:apexcharts, format: :js, locale: nil), nonce: content_security_policy_nonce) %>
90
89
 
91
90
  <script type="module" nonce="<%= content_security_policy_nonce %>">
92
91
  import "application";
data/config/routes.rb CHANGED
@@ -99,7 +99,7 @@ Pgbus::Engine.routes.draw do
99
99
  end
100
100
 
101
101
  scope :frontend, controller: :frontends, defaults: { version: Pgbus::VERSION.tr(".", "-") } do
102
- get "modules/:version/:id", action: :module, as: :frontend_module, constraints: { format: "js" }
102
+ get "modules/:version/:id", action: :module, as: :frontend_module, constraints: { format: /js|map/ }
103
103
  get "static/:version/:id", action: :static, as: :frontend_static
104
104
  end
105
105
  end
@@ -33,7 +33,8 @@ module Pgbus
33
33
  say " 2. Use in your code:"
34
34
  say " Pgbus.stream(@room).presence.join(member_id: current_user.id.to_s)"
35
35
  say " Pgbus.stream(@room).presence.members"
36
- say " 3. Run a periodic sweeper to expire idle members:"
36
+ say " 3. Connection-driven presence (streams_presence_patterns) expires silent"
37
+ say " seats on its own. Rows you join by member_id need a periodic sweeper:"
37
38
  say " Pgbus.stream(@room).presence.sweep!(older_than: 60.seconds.ago)"
38
39
  say ""
39
40
  end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+ require_relative "migration_path"
6
+
7
+ module Pgbus
8
+ module Generators
9
+ class AddPresenceSeatsGenerator < Rails::Generators::Base
10
+ include ActiveRecord::Generators::Migration
11
+ include MigrationPath
12
+
13
+ source_root File.expand_path("templates", __dir__)
14
+
15
+ desc "Add per-connection seats (connection_id, seat, state) to pgbus_presence_members"
16
+
17
+ class_option :database,
18
+ type: :string,
19
+ default: nil,
20
+ desc: "Use a separate database for pgbus tables (e.g. --database=pgbus)"
21
+
22
+ def create_migration_file
23
+ migration_template "add_presence_seats.rb.erb",
24
+ File.join(pgbus_migrate_path, "add_pgbus_presence_seats.rb")
25
+ end
26
+
27
+ def display_post_install
28
+ say ""
29
+ say "Pgbus presence seats installed!", :green
30
+ say ""
31
+ say "Next steps:"
32
+ say " 1. Run: rails db:migrate#{migrate_command_suffix}"
33
+ say " 2. Restart the app: connection-driven presence (streams_presence_patterns)"
34
+ say " then keeps one row per SSE connection, expired after streams_presence_ttl"
35
+ say ""
36
+ end
37
+
38
+ private
39
+
40
+ def migration_version
41
+ "[#{ActiveRecord::Migration.current_version}]"
42
+ end
43
+ end
44
+ end
45
+ end
@@ -4,23 +4,48 @@ class AddPgbusPresence < ActiveRecord::Migration<%= migration_version %>
4
4
  t.string :stream_name, null: false
5
5
  t.string :member_id, null: false
6
6
  t.jsonb :metadata, null: false, default: {}
7
+ # One row per SSE connection for connection-driven presence (seats).
8
+ # NULL for rows written by member-keyed join/leave calls.
9
+ t.string :connection_id
10
+ # The client's per-tab id (sent by <pgbus-stream-source presence> as ?seat=).
11
+ t.string :seat
12
+ # Ephemeral per-seat state, written by Presence#set_state.
13
+ t.jsonb :state, null: false, default: {}
7
14
  t.datetime :joined_at, null: false, default: -> { "CURRENT_TIMESTAMP" }
8
15
  t.datetime :last_seen_at, null: false, default: -> { "CURRENT_TIMESTAMP" }
9
16
  end
10
17
 
11
- # Composite primary key (stream_name, member_id) — a member can only
12
- # appear once per stream. Used by the upsert path in
13
- # Pgbus::Streams::Presence#join.
18
+ # A member-keyed row (no connection) appears once per stream. Upsert
19
+ # target of Pgbus::Streams::Presence#join(member_id:).
14
20
  add_index :pgbus_presence_members,
15
21
  %i[stream_name member_id],
16
22
  unique: true,
17
- name: "idx_pgbus_presence_members_pk"
23
+ where: "connection_id IS NULL",
24
+ name: "idx_pgbus_presence_members_member_key"
18
25
 
19
- # Sweeping by last_seen_at expires stale members. The Dispatcher's
20
- # periodic sweeper queries WHERE stream_name = ? AND last_seen_at < ?,
21
- # so this composite index makes that fast.
26
+ # A seat row appears once per connection. Upsert target of
27
+ # Pgbus::Streams::Presence#join(connection_id:).
28
+ add_index :pgbus_presence_members,
29
+ %i[stream_name connection_id],
30
+ unique: true,
31
+ where: "connection_id IS NOT NULL",
32
+ name: "idx_pgbus_presence_members_connection_key"
33
+
34
+ # All rows of one member (several tabs) on a stream.
35
+ add_index :pgbus_presence_members,
36
+ %i[stream_name member_id],
37
+ name: "idx_pgbus_presence_members_member"
38
+
39
+ # Sweeping by last_seen_at expires stale members:
40
+ # WHERE stream_name = ? AND last_seen_at < ?
22
41
  add_index :pgbus_presence_members,
23
42
  %i[stream_name last_seen_at],
24
43
  name: "idx_pgbus_presence_members_sweep"
44
+
45
+ # The streamer heartbeat expires silent seats across every stream.
46
+ add_index :pgbus_presence_members,
47
+ :last_seen_at,
48
+ where: "connection_id IS NOT NULL",
49
+ name: "idx_pgbus_presence_members_seat_expiry"
25
50
  end
26
51
  end
@@ -0,0 +1,38 @@
1
+ class AddPgbusPresenceSeats < ActiveRecord::Migration<%= migration_version %>
2
+ def up
3
+ add_column :pgbus_presence_members, :connection_id, :string
4
+ add_column :pgbus_presence_members, :seat, :string
5
+ add_column :pgbus_presence_members, :state, :jsonb, null: false, default: {}
6
+
7
+ # Existing rows are member-keyed (connection_id IS NULL) and keep their
8
+ # one-row-per-member key; seat rows get one row per connection.
9
+ remove_index :pgbus_presence_members, name: "idx_pgbus_presence_members_pk"
10
+ add_index :pgbus_presence_members, %i[stream_name member_id],
11
+ unique: true, where: "connection_id IS NULL",
12
+ name: "idx_pgbus_presence_members_member_key"
13
+ add_index :pgbus_presence_members, %i[stream_name connection_id],
14
+ unique: true, where: "connection_id IS NOT NULL",
15
+ name: "idx_pgbus_presence_members_connection_key"
16
+ add_index :pgbus_presence_members, %i[stream_name member_id],
17
+ name: "idx_pgbus_presence_members_member"
18
+ add_index :pgbus_presence_members, :last_seen_at,
19
+ where: "connection_id IS NOT NULL",
20
+ name: "idx_pgbus_presence_members_seat_expiry"
21
+ end
22
+
23
+ def down
24
+ # Seat rows cannot satisfy the old one-row-per-member key.
25
+ execute "DELETE FROM pgbus_presence_members WHERE connection_id IS NOT NULL"
26
+
27
+ remove_index :pgbus_presence_members, name: "idx_pgbus_presence_members_seat_expiry"
28
+ remove_index :pgbus_presence_members, name: "idx_pgbus_presence_members_member"
29
+ remove_index :pgbus_presence_members, name: "idx_pgbus_presence_members_connection_key"
30
+ remove_index :pgbus_presence_members, name: "idx_pgbus_presence_members_member_key"
31
+ add_index :pgbus_presence_members, %i[stream_name member_id],
32
+ unique: true, name: "idx_pgbus_presence_members_pk"
33
+
34
+ remove_column :pgbus_presence_members, :state
35
+ remove_column :pgbus_presence_members, :seat
36
+ remove_column :pgbus_presence_members, :connection_id
37
+ end
38
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ class Configuration
5
+ # Seat expiry and the change hook for connection-driven presence
6
+ # (issue #515). EXPERIMENTAL, like the other streams_presence_* settings.
7
+ #
8
+ # config.streams_presence_ttl = 60 # every stream
9
+ # config.streams_presence_ttl = { /^doc:/ => 5.minutes, default: 60 } # per pattern
10
+ # config.streams_presence_on_change = ->(stream_name, change) { ... }
11
+ #
12
+ # `change` is { type: :join | :leave, connection_id:, seat:, member_id: }.
13
+ # An expired seat is reported as a :leave.
14
+ module PresenceSettings
15
+ DEFAULT_PRESENCE_TTL = 60
16
+
17
+ attr_accessor :streams_presence_on_change
18
+ attr_writer :streams_presence_ttl
19
+
20
+ def streams_presence_ttl
21
+ @streams_presence_ttl.nil? ? DEFAULT_PRESENCE_TTL : @streams_presence_ttl
22
+ end
23
+
24
+ # Seconds a seat may stay silent on `stream_name` before the heartbeat
25
+ # expires it. A pattern matches like streams_presence_patterns (exact
26
+ # String or Regexp); the first match wins.
27
+ def presence_ttl_for(stream_name)
28
+ ttl = streams_presence_ttl
29
+ return presence_seconds(ttl) unless ttl.is_a?(Hash)
30
+
31
+ match = ttl.find { |pattern, _| pattern_matches?(pattern, stream_name) }
32
+ presence_seconds(match ? match.last : ttl.fetch(:default, DEFAULT_PRESENCE_TTL))
33
+ end
34
+
35
+ # The smallest ttl configured: the expiry scan's candidate cutoff.
36
+ def presence_min_ttl
37
+ ttl = streams_presence_ttl
38
+ return presence_seconds(ttl) unless ttl.is_a?(Hash)
39
+
40
+ [*ttl.values, ttl.fetch(:default, DEFAULT_PRESENCE_TTL)].map { |v| presence_seconds(v) }.min
41
+ end
42
+
43
+ # Reports one join, leave or expiry to streams_presence_on_change.
44
+ # A raising hook is logged and swallowed, like the rest of the
45
+ # presence path: it must not break a connection or a sweep.
46
+ def notify_presence_change(stream_name, change)
47
+ hook = streams_presence_on_change
48
+ return unless hook
49
+
50
+ hook.call(stream_name, change)
51
+ rescue StandardError => e
52
+ Pgbus.logger.error do
53
+ "[Pgbus] streams_presence_on_change raised for #{stream_name}: #{e.class}: #{e.message}"
54
+ end
55
+ end
56
+
57
+ private
58
+
59
+ def validate_presence_settings!
60
+ if !streams_presence_on_change.nil? && !streams_presence_on_change.respond_to?(:call)
61
+ raise Pgbus::ConfigurationError,
62
+ "streams_presence_on_change must respond to #call (a Proc/lambda) or be nil"
63
+ end
64
+
65
+ ttl = streams_presence_ttl
66
+ values = ttl.is_a?(Hash) ? ttl.values : [ttl]
67
+ unless values.all? { |v| v.is_a?(Numeric) && v.to_f.positive? }
68
+ raise Pgbus::ConfigurationError,
69
+ "streams_presence_ttl must be a positive number of seconds or a Hash of pattern => seconds"
70
+ end
71
+
72
+ warn_presence_ttl_below_heartbeat
73
+ end
74
+
75
+ # A ttl at or below the heartbeat interval lets another process expire
76
+ # live seats between touches, so every tick reports a leave and a join.
77
+ def warn_presence_ttl_below_heartbeat
78
+ heartbeat = streams_heartbeat_interval
79
+ return unless heartbeat.is_a?(Numeric) && presence_min_ttl <= heartbeat
80
+
81
+ Pgbus.logger.warn do
82
+ "[Pgbus] streams_presence_ttl (#{presence_min_ttl}s) should exceed streams_heartbeat_interval " \
83
+ "(#{heartbeat}s), or live presence seats are expired and re-joined every tick"
84
+ end
85
+ end
86
+
87
+ # Seconds as an Integer when whole, a Float otherwise (a Duration
88
+ # becomes plain seconds), so fractional ttls are not truncated.
89
+ def presence_seconds(value)
90
+ seconds = value.to_f
91
+ seconds == seconds.floor ? seconds.to_i : seconds
92
+ end
93
+
94
+ def pattern_matches?(pattern, stream_name)
95
+ case pattern
96
+ when Regexp then pattern.match?(stream_name)
97
+ when :default then false
98
+ else pattern.to_s == stream_name
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -4,6 +4,8 @@ require "logger"
4
4
 
5
5
  module Pgbus
6
6
  class Configuration
7
+ include PresenceSettings
8
+
7
9
  # Connection settings
8
10
  attr_accessor :database_url, :connection_params, :pool_size, :pool_timeout
9
11
 
@@ -1093,6 +1095,8 @@ module Pgbus
1093
1095
  raise Pgbus::ConfigurationError, "streams_presence_member must respond to #call (a Proc/lambda) or be nil"
1094
1096
  end
1095
1097
 
1098
+ validate_presence_settings!
1099
+
1096
1100
  return if streams_orphan_threshold.nil?
1097
1101
  return if streams_orphan_threshold.is_a?(Numeric) && streams_orphan_threshold.positive?
1098
1102
 
data/lib/pgbus/engine.rb CHANGED
@@ -139,14 +139,20 @@ module Pgbus
139
139
  app.middleware.use Pgbus::Streams::WatermarkCacheMiddleware if Pgbus.configuration.streams_enabled
140
140
  end
141
141
 
142
- # Make stream_source_element.js available to the host app's asset
143
- # pipeline (Propshaft or Sprockets) so it can be included via
144
- # `javascript_include_tag "pgbus/stream_source_element"` or pinned
145
- # in importmap. When importmap-rails is loaded, auto-pin it so
146
- # host apps get it without manual configuration.
142
+ # Make the <pgbus-stream-source> element available to the host app's
143
+ # asset pipeline (Propshaft or Sprockets). The importmap pin points at
144
+ # the minified build (rake frontend:js). Propshaft rewrites its
145
+ # sourceMappingURL to the digested .min.js.map; Sprockets serves the
146
+ # comment as written. The readable ES module source stays precompiled for
147
+ # apps that pin or bundle it themselves. When importmap-rails is loaded,
148
+ # auto-pin it so host apps get it without manual configuration.
147
149
  initializer "pgbus.streams.assets" do |app|
148
150
  if Pgbus.configuration.streams_enabled && app.config.respond_to?(:assets)
149
- app.config.assets.precompile += %w[pgbus/stream_source_element.js]
151
+ app.config.assets.precompile += %w[
152
+ pgbus/stream_source_element.min.js
153
+ pgbus/stream_source_element.min.js.map
154
+ pgbus/stream_source_element.js
155
+ ]
150
156
  end
151
157
  end
152
158
 
@@ -156,7 +162,7 @@ module Pgbus
156
162
  next unless Rails.application.respond_to?(:importmap)
157
163
 
158
164
  Rails.application.importmap.pin(
159
- "pgbus/stream_source_element", to: "pgbus/stream_source_element.js"
165
+ "pgbus/stream_source_element", to: "pgbus/stream_source_element.min.js"
160
166
  )
161
167
  end
162
168
  end
@@ -73,6 +73,7 @@ module Pgbus
73
73
  add_stream_stats: "pgbus:add_stream_stats",
74
74
  add_stream_queues: "pgbus:add_stream_queues",
75
75
  add_presence: "pgbus:add_presence",
76
+ add_presence_seats: "pgbus:add_presence_seats",
76
77
  add_queue_states: "pgbus:add_queue_states",
77
78
  add_outbox: "pgbus:add_outbox",
78
79
  add_recurring: "pgbus:add_recurring",
@@ -94,6 +95,7 @@ module Pgbus
94
95
  add_stream_stats: "stream stats table (opt-in real-time Insights)",
95
96
  add_stream_queues: "stream queue registry (per-stream retention, orphan sweep, wildcard-worker safety)",
96
97
  add_presence: "presence members table (Turbo Streams presence)",
98
+ add_presence_seats: "presence seats (one row per SSE connection, per-seat state)",
97
99
  add_queue_states: "queue states table (pause/resume)",
98
100
  add_outbox: "outbox entries table (transactional outbox)",
99
101
  add_recurring: "recurring tasks + executions tables",
@@ -182,8 +184,14 @@ module Pgbus
182
184
  table_exists?("pgbus_stream_queues") ? [] : [:add_stream_queues]
183
185
  end
184
186
 
187
+ # The add_presence template already carries the seat columns, so a
188
+ # missing table needs only that; an existing table without
189
+ # connection_id predates seats (issue #515).
185
190
  def presence_migrations
186
- table_exists?("pgbus_presence_members") ? [] : [:add_presence]
191
+ return [:add_presence] unless table_exists?("pgbus_presence_members")
192
+ return [] if column_names("pgbus_presence_members").include?("connection_id")
193
+
194
+ [:add_presence_seats]
187
195
  end
188
196
 
189
197
  def queue_states_migrations