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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +4 -0
- data/README.md +35 -0
- data/Rakefile +26 -0
- data/app/assets/javascripts/pgbus/stream_source_element.js +34 -2
- data/app/assets/javascripts/pgbus/stream_source_element.min.js +6 -0
- data/app/assets/javascripts/pgbus/stream_source_element.min.js.map +1 -0
- data/app/controllers/pgbus/frontends_controller.rb +25 -11
- data/app/frontend/pgbus/application.min.js +2 -0
- data/app/frontend/pgbus/application.min.js.map +1 -0
- data/app/frontend/pgbus/modules/charts.js +24 -6
- data/app/frontend/pgbus/modules/charts.min.js +2 -0
- data/app/frontend/pgbus/modules/charts.min.js.map +1 -0
- data/app/frontend/pgbus/vendor/turbo.min.js +54 -0
- data/app/frontend/pgbus/vendor/turbo.min.js.map +1 -0
- data/app/views/layouts/pgbus/application.html.erb +0 -1
- data/config/routes.rb +1 -1
- data/lib/generators/pgbus/add_presence_generator.rb +2 -1
- data/lib/generators/pgbus/add_presence_seats_generator.rb +45 -0
- data/lib/generators/pgbus/templates/add_presence.rb.erb +32 -7
- data/lib/generators/pgbus/templates/add_presence_seats.rb.erb +38 -0
- data/lib/pgbus/configuration/presence_settings.rb +103 -0
- data/lib/pgbus/configuration.rb +4 -0
- data/lib/pgbus/engine.rb +13 -7
- data/lib/pgbus/generators/migration_detector.rb +9 -1
- data/lib/pgbus/streams/presence.rb +317 -81
- data/lib/pgbus/version.rb +1 -1
- data/lib/pgbus/web/stream_app.rb +18 -6
- data/lib/pgbus/web/streamer/connection.rb +7 -1
- data/lib/pgbus/web/streamer/falcon_connection.rb +7 -1
- data/lib/pgbus/web/streamer/heartbeat.rb +9 -3
- data/lib/pgbus/web/streamer/instance.rb +2 -1
- data/lib/pgbus/web/streamer/presence_tracker.rb +155 -0
- data/lib/pgbus/web/streamer/stream_event_dispatcher.rb +8 -37
- 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:
|
|
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.
|
|
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
|
-
#
|
|
12
|
-
#
|
|
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
|
-
|
|
23
|
+
where: "connection_id IS NULL",
|
|
24
|
+
name: "idx_pgbus_presence_members_member_key"
|
|
18
25
|
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
|
|
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
|
data/lib/pgbus/configuration.rb
CHANGED
|
@@ -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
|
|
143
|
-
# pipeline (Propshaft or Sprockets)
|
|
144
|
-
#
|
|
145
|
-
#
|
|
146
|
-
#
|
|
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[
|
|
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")
|
|
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
|