pgbus 0.5.1 → 0.6.1

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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +244 -1
  3. data/Rakefile +8 -1
  4. data/app/controllers/pgbus/insights_controller.rb +6 -0
  5. data/app/helpers/pgbus/streams_helper.rb +115 -0
  6. data/app/javascript/pgbus/stream_source_element.js +212 -0
  7. data/app/models/pgbus/stream_stat.rb +123 -0
  8. data/app/views/pgbus/insights/show.html.erb +59 -0
  9. data/config/locales/en.yml +16 -0
  10. data/config/routes.rb +11 -0
  11. data/lib/generators/pgbus/add_job_stats_queue_index_generator.rb +53 -0
  12. data/lib/generators/pgbus/add_presence_generator.rb +55 -0
  13. data/lib/generators/pgbus/add_stream_stats_generator.rb +54 -0
  14. data/lib/generators/pgbus/templates/add_job_stats_latency.rb.erb +4 -1
  15. data/lib/generators/pgbus/templates/add_job_stats_queue_index.rb.erb +11 -0
  16. data/lib/generators/pgbus/templates/add_presence.rb.erb +26 -0
  17. data/lib/generators/pgbus/templates/add_stream_stats.rb.erb +18 -0
  18. data/lib/generators/pgbus/update_generator.rb +176 -23
  19. data/lib/pgbus/client/ensure_stream_queue.rb +54 -0
  20. data/lib/pgbus/client/read_after.rb +100 -0
  21. data/lib/pgbus/client.rb +6 -0
  22. data/lib/pgbus/configuration.rb +65 -0
  23. data/lib/pgbus/engine.rb +31 -0
  24. data/lib/pgbus/generators/config_converter.rb +22 -2
  25. data/lib/pgbus/generators/database_target_detector.rb +94 -0
  26. data/lib/pgbus/generators/migration_detector.rb +217 -0
  27. data/lib/pgbus/process/dispatcher.rb +62 -4
  28. data/lib/pgbus/streams/cursor.rb +71 -0
  29. data/lib/pgbus/streams/envelope.rb +58 -0
  30. data/lib/pgbus/streams/filters.rb +98 -0
  31. data/lib/pgbus/streams/presence.rb +216 -0
  32. data/lib/pgbus/streams/signed_name.rb +69 -0
  33. data/lib/pgbus/streams/turbo_broadcastable.rb +53 -0
  34. data/lib/pgbus/streams/watermark_cache_middleware.rb +28 -0
  35. data/lib/pgbus/streams.rb +151 -0
  36. data/lib/pgbus/version.rb +1 -1
  37. data/lib/pgbus/web/data_source.rb +88 -10
  38. data/lib/pgbus/web/stream_app.rb +179 -0
  39. data/lib/pgbus/web/streamer/connection.rb +122 -0
  40. data/lib/pgbus/web/streamer/dispatcher.rb +467 -0
  41. data/lib/pgbus/web/streamer/heartbeat.rb +105 -0
  42. data/lib/pgbus/web/streamer/instance.rb +176 -0
  43. data/lib/pgbus/web/streamer/io_writer.rb +73 -0
  44. data/lib/pgbus/web/streamer/listener.rb +228 -0
  45. data/lib/pgbus/web/streamer/registry.rb +103 -0
  46. data/lib/pgbus/web/streamer.rb +53 -0
  47. data/lib/pgbus.rb +28 -0
  48. data/lib/puma/plugin/pgbus_streams.rb +54 -0
  49. data/lib/tasks/pgbus_streams.rake +52 -0
  50. metadata +33 -1
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ # Records one row per stream event (broadcast / connect / disconnect)
5
+ # when `config.streams_stats_enabled` is true. Mirrors JobStat in
6
+ # shape and aggregation API so the Insights controller can query
7
+ # both with the same patterns.
8
+ #
9
+ # Writes are fire-and-forget: any error is swallowed so a stat
10
+ # recording failure cannot affect the dispatcher's hot path.
11
+ class StreamStat < BusRecord
12
+ self.table_name = "pgbus_stream_stats"
13
+
14
+ EVENT_TYPES = %w[broadcast connect disconnect].freeze
15
+
16
+ scope :since, ->(time) { where("created_at >= ?", time) }
17
+ # NOTE: scope names intentionally avoid `broadcasts` / `connects` /
18
+ # `disconnects`. `turbo-rails` auto-includes `Turbo::Broadcastable`
19
+ # into every AR model, which defines a class method `broadcasts`.
20
+ # AR's dangerous_class_method? guard then rejects a colliding
21
+ # scope name at load time, crashing eager_load. See issue #92.
22
+ scope :broadcast_events, -> { where(event_type: "broadcast") }
23
+ scope :connect_events, -> { where(event_type: "connect") }
24
+ scope :disconnect_events, -> { where(event_type: "disconnect") }
25
+
26
+ # Records a stream event. Called from the Dispatcher when the
27
+ # `streams_stats_enabled` flag is set. Errors are swallowed so a
28
+ # missing table or a connection blip cannot kill the dispatcher.
29
+ def self.record!(stream_name:, event_type:, duration_ms: 0, fanout: nil)
30
+ return unless table_exists?
31
+
32
+ create!(
33
+ stream_name: stream_name,
34
+ event_type: event_type,
35
+ duration_ms: duration_ms.to_i,
36
+ fanout: fanout
37
+ )
38
+ rescue StandardError => e
39
+ Pgbus.logger.debug { "[Pgbus] Failed to record stream stat: #{e.message}" }
40
+ end
41
+
42
+ # Memoized — intentionally never invalidated at runtime. If the
43
+ # pgbus_stream_stats migration runs while the app is already
44
+ # running, a restart is required for stat recording to begin.
45
+ #
46
+ # We only memoize a *successful* probe. A transient error (PG
47
+ # hiccup during boot, connection refused during a failover) is
48
+ # treated as "don't know yet" — the next call retries. Caching
49
+ # false on the first hiccup would permanently disable stream
50
+ # stats for the process lifetime, which is a worse failure mode
51
+ # than a few retries.
52
+ def self.table_exists?
53
+ return @table_exists if defined?(@table_exists)
54
+
55
+ @table_exists = connection.table_exists?(table_name)
56
+ rescue StandardError => e
57
+ Pgbus.logger.debug { "[Pgbus] Failed to check stream stat table: #{e.message}" }
58
+ false
59
+ end
60
+
61
+ # Single-query aggregate summary over the given window.
62
+ # Returns totals by event type, average fanout for broadcasts,
63
+ # and an "active" estimate (connects − disconnects in window).
64
+ def self.summary(minutes: 60)
65
+ row = since(minutes.minutes.ago).pick(
66
+ Arel.sql("COUNT(*) FILTER (WHERE event_type = 'broadcast')"),
67
+ Arel.sql("COUNT(*) FILTER (WHERE event_type = 'connect')"),
68
+ Arel.sql("COUNT(*) FILTER (WHERE event_type = 'disconnect')"),
69
+ Arel.sql("ROUND(AVG(fanout) FILTER (WHERE event_type = 'broadcast')::numeric, 1)"),
70
+ Arel.sql("ROUND(AVG(duration_ms) FILTER (WHERE event_type = 'broadcast')::numeric, 1)"),
71
+ Arel.sql("ROUND(AVG(duration_ms) FILTER (WHERE event_type = 'connect')::numeric, 1)")
72
+ )
73
+
74
+ {
75
+ broadcasts: row[0].to_i,
76
+ connects: row[1].to_i,
77
+ disconnects: row[2].to_i,
78
+ active_estimate: [row[1].to_i - row[2].to_i, 0].max,
79
+ avg_fanout: row[3]&.to_f || 0,
80
+ avg_broadcast_ms: row[4]&.to_f || 0,
81
+ avg_connect_ms: row[5]&.to_f || 0
82
+ }
83
+ end
84
+
85
+ # Top N streams by broadcast count in the window, with avg fanout.
86
+ def self.top_streams(limit: 10, minutes: 60)
87
+ broadcast_events
88
+ .since(minutes.minutes.ago)
89
+ .group(:stream_name)
90
+ .order(Arel.sql("COUNT(*) DESC"))
91
+ .limit(limit)
92
+ .pluck(
93
+ :stream_name,
94
+ Arel.sql("COUNT(*)"),
95
+ Arel.sql("ROUND(AVG(fanout)::numeric, 1)"),
96
+ Arel.sql("ROUND(AVG(duration_ms)::numeric, 1)")
97
+ )
98
+ .map do |name, count, avg_fanout, avg_ms|
99
+ {
100
+ stream_name: name,
101
+ count: count.to_i,
102
+ avg_fanout: avg_fanout&.to_f || 0,
103
+ avg_ms: avg_ms&.to_f || 0
104
+ }
105
+ end
106
+ end
107
+
108
+ # Throughput: broadcast events per minute bucketed by minute.
109
+ def self.throughput(minutes: 60)
110
+ broadcast_events
111
+ .since(minutes.minutes.ago)
112
+ .group("date_trunc('minute', created_at)")
113
+ .order(Arel.sql("date_trunc('minute', created_at)"))
114
+ .count
115
+ end
116
+
117
+ # Cleanup old stats. Called from Pgbus::Process::Dispatcher on the
118
+ # same cadence as JobStat.cleanup! using the shared stats_retention.
119
+ def self.cleanup!(older_than:)
120
+ where("created_at < ?", older_than).delete_all
121
+ end
122
+ end
123
+ end
@@ -159,6 +159,65 @@
159
159
  </table>
160
160
  </div>
161
161
 
162
+ <% if @stream_stats_available %>
163
+ <!-- Stream stats -->
164
+ <div class="mt-8">
165
+ <h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4"><%= t("pgbus.insights.show.streams.title") %></h2>
166
+
167
+ <div class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-5 mb-6">
168
+ <div class="rounded-lg bg-white dark:bg-gray-800 p-4 shadow ring-1 ring-gray-200 dark:ring-gray-700">
169
+ <dt class="text-xs font-medium uppercase text-gray-500 dark:text-gray-400"><%= t("pgbus.insights.show.streams.summary.broadcasts") %></dt>
170
+ <dd class="mt-1 text-2xl font-semibold text-gray-900 dark:text-white"><%= pgbus_number(@stream_summary[:broadcasts]) %></dd>
171
+ </div>
172
+ <div class="rounded-lg bg-white dark:bg-gray-800 p-4 shadow ring-1 ring-gray-200 dark:ring-gray-700">
173
+ <dt class="text-xs font-medium uppercase text-gray-500 dark:text-gray-400"><%= t("pgbus.insights.show.streams.summary.connects") %></dt>
174
+ <dd class="mt-1 text-2xl font-semibold text-green-600 dark:text-green-400"><%= pgbus_number(@stream_summary[:connects]) %></dd>
175
+ </div>
176
+ <div class="rounded-lg bg-white dark:bg-gray-800 p-4 shadow ring-1 ring-gray-200 dark:ring-gray-700">
177
+ <dt class="text-xs font-medium uppercase text-gray-500 dark:text-gray-400"><%= t("pgbus.insights.show.streams.summary.disconnects") %></dt>
178
+ <dd class="mt-1 text-2xl font-semibold text-orange-600 dark:text-orange-400"><%= pgbus_number(@stream_summary[:disconnects]) %></dd>
179
+ </div>
180
+ <div class="rounded-lg bg-white dark:bg-gray-800 p-4 shadow ring-1 ring-gray-200 dark:ring-gray-700">
181
+ <dt class="text-xs font-medium uppercase text-gray-500 dark:text-gray-400"><%= t("pgbus.insights.show.streams.summary.active") %></dt>
182
+ <dd class="mt-1 text-2xl font-semibold text-gray-900 dark:text-white"><%= pgbus_number(@stream_summary[:active_estimate]) %></dd>
183
+ </div>
184
+ <div class="rounded-lg bg-white dark:bg-gray-800 p-4 shadow ring-1 ring-gray-200 dark:ring-gray-700">
185
+ <dt class="text-xs font-medium uppercase text-gray-500 dark:text-gray-400"><%= t("pgbus.insights.show.streams.summary.avg_fanout") %></dt>
186
+ <dd class="mt-1 text-2xl font-semibold text-gray-900 dark:text-white"><%= @stream_summary[:avg_fanout]&.round(1) || 0 %></dd>
187
+ </div>
188
+ </div>
189
+
190
+ <div class="rounded-lg bg-white dark:bg-gray-800 shadow ring-1 ring-gray-200 dark:ring-gray-700">
191
+ <div class="px-5 py-4 border-b border-gray-200 dark:border-gray-700">
192
+ <h3 class="text-sm font-medium text-gray-700 dark:text-gray-300"><%= t("pgbus.insights.show.streams.top.title") %></h3>
193
+ </div>
194
+ <table class="pgbus-table min-w-full divide-y divide-gray-200 dark:divide-gray-700">
195
+ <thead class="bg-gray-50 dark:bg-gray-900">
196
+ <tr>
197
+ <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500 dark:text-gray-400"><%= t("pgbus.insights.show.streams.top.headers.stream") %></th>
198
+ <th class="px-4 py-3 text-right text-xs font-medium uppercase text-gray-500 dark:text-gray-400"><%= t("pgbus.insights.show.streams.top.headers.broadcasts") %></th>
199
+ <th class="px-4 py-3 text-right text-xs font-medium uppercase text-gray-500 dark:text-gray-400"><%= t("pgbus.insights.show.streams.top.headers.avg_fanout") %></th>
200
+ <th class="px-4 py-3 text-right text-xs font-medium uppercase text-gray-500 dark:text-gray-400"><%= t("pgbus.insights.show.streams.top.headers.avg_ms") %></th>
201
+ </tr>
202
+ </thead>
203
+ <tbody class="divide-y divide-gray-100 dark:divide-gray-700">
204
+ <% @top_streams.each do |row| %>
205
+ <tr class="hover:bg-gray-50 dark:hover:bg-gray-700/50">
206
+ <td data-label="Stream" class="px-4 py-3 text-sm font-medium text-gray-700 dark:text-gray-300"><%= row[:stream_name] %></td>
207
+ <td data-label="Broadcasts" class="px-4 py-3 text-sm text-right font-mono text-gray-700 dark:text-gray-300"><%= pgbus_number(row[:count]) %></td>
208
+ <td data-label="Avg Fanout" class="px-4 py-3 text-sm text-right font-mono text-gray-700 dark:text-gray-300"><%= row[:avg_fanout]&.round(1) || 0 %></td>
209
+ <td data-label="Avg Duration" class="px-4 py-3 text-sm text-right font-mono text-gray-700 dark:text-gray-300"><%= pgbus_ms_duration(row[:avg_ms]) %></td>
210
+ </tr>
211
+ <% end %>
212
+ <% if @top_streams.empty? %>
213
+ <tr><td colspan="4" class="px-4 py-8 text-center text-sm text-gray-400 dark:text-gray-500"><%= t("pgbus.insights.show.streams.top.empty") %></td></tr>
214
+ <% end %>
215
+ </tbody>
216
+ </table>
217
+ </div>
218
+ </div>
219
+ <% end %>
220
+
162
221
  <script type="module" nonce="<%= content_security_policy_nonce %>">
163
222
  import { renderCharts, observeThemeChanges } from "charts";
164
223
 
@@ -161,6 +161,22 @@ en:
161
161
  job_class: Job Class
162
162
  max: Max
163
163
  title: Slowest Job Classes (avg duration)
164
+ streams:
165
+ summary:
166
+ active: Active
167
+ avg_fanout: Avg Fanout
168
+ broadcasts: Broadcasts
169
+ connects: Connects
170
+ disconnects: Disconnects
171
+ title: Real-time Streams
172
+ top:
173
+ empty: No stream activity recorded in the selected window
174
+ headers:
175
+ avg_fanout: Avg Fanout
176
+ avg_ms: Avg Dispatch
177
+ broadcasts: Broadcasts
178
+ stream: Stream
179
+ title: Top Streams by Broadcast Volume
164
180
  summary:
165
181
  avg_duration: Avg Duration
166
182
  avg_latency: Avg Latency
data/config/routes.rb CHANGED
@@ -1,6 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  Pgbus::Engine.routes.draw do
4
+ # SSE streaming endpoint for the pgbus_stream_from turbo-rails replacement.
5
+ # Mounted as a bare Rack app so it bypasses the entire Rails middleware
6
+ # stack — see lib/pgbus/web/stream_app.rb for the rationale. Named with
7
+ # `as: :streams` so `Pgbus::Engine.routes.url_helpers.streams_path`
8
+ # resolves to whatever mount point the host app chose for the engine,
9
+ # not a hardcoded `/pgbus/streams`. The helper under
10
+ # app/helpers/pgbus/streams_helper.rb appends `/:signed_name` to that
11
+ # base so the full URL works whether the engine is at /pgbus, /admin,
12
+ # or anywhere else.
13
+ mount Pgbus::Web::StreamApp.new => "/streams", as: :streams if Pgbus.configuration.streams_enabled
14
+
4
15
  root to: "dashboard#show"
5
16
 
6
17
  resources :queues, only: %i[index show destroy], param: :name do
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+
6
+ module Pgbus
7
+ module Generators
8
+ class AddJobStatsQueueIndexGenerator < Rails::Generators::Base
9
+ include ActiveRecord::Generators::Migration
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+
13
+ desc "Add composite index on pgbus_job_stats (queue_name, created_at) for Insights latency-by-queue aggregation"
14
+
15
+ class_option :database,
16
+ type: :string,
17
+ default: nil,
18
+ desc: "Use a separate database for pgbus tables (e.g. --database=pgbus)"
19
+
20
+ def create_migration_file
21
+ if separate_database?
22
+ migration_template "add_job_stats_queue_index.rb.erb",
23
+ "db/pgbus_migrate/add_pgbus_job_stats_queue_index.rb"
24
+ else
25
+ migration_template "add_job_stats_queue_index.rb.erb",
26
+ "db/migrate/add_pgbus_job_stats_queue_index.rb"
27
+ end
28
+ end
29
+
30
+ def display_post_install
31
+ say ""
32
+ say "Pgbus job stats queue index added!", :green
33
+ say ""
34
+ say "Next steps:"
35
+ say " 1. Run: rails db:migrate#{":#{options[:database]}" if separate_database?}"
36
+ say " 2. The Insights 'latency by queue' aggregation will now use the index"
37
+ say " instead of sequentially scanning pgbus_job_stats. Install this on"
38
+ say " heavy-traffic deployments with a large job stats retention window."
39
+ say ""
40
+ end
41
+
42
+ private
43
+
44
+ def migration_version
45
+ "[#{ActiveRecord::Migration.current_version}]"
46
+ end
47
+
48
+ def separate_database?
49
+ options[:database].present?
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+
6
+ module Pgbus
7
+ module Generators
8
+ class AddPresenceGenerator < Rails::Generators::Base
9
+ include ActiveRecord::Generators::Migration
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+
13
+ desc "Add presence_members table for Pgbus::Streams presence tracking"
14
+
15
+ class_option :database,
16
+ type: :string,
17
+ default: nil,
18
+ desc: "Use a separate database for pgbus tables (e.g. --database=pgbus)"
19
+
20
+ def create_migration_file
21
+ if separate_database?
22
+ migration_template "add_presence.rb.erb",
23
+ "db/pgbus_migrate/add_pgbus_presence.rb"
24
+ else
25
+ migration_template "add_presence.rb.erb",
26
+ "db/migrate/add_pgbus_presence.rb"
27
+ end
28
+ end
29
+
30
+ def display_post_install
31
+ say ""
32
+ say "Pgbus presence installed!", :green
33
+ say ""
34
+ say "Next steps:"
35
+ say " 1. Run: rails db:migrate#{":#{options[:database]}" if separate_database?}"
36
+ say " 2. Use in your code:"
37
+ say " Pgbus.stream(@room).presence.join(member_id: current_user.id.to_s)"
38
+ say " Pgbus.stream(@room).presence.members"
39
+ say " 3. Run a periodic sweeper to expire idle members:"
40
+ say " Pgbus.stream(@room).presence.sweep!(older_than: 60.seconds.ago)"
41
+ say ""
42
+ end
43
+
44
+ private
45
+
46
+ def migration_version
47
+ "[#{ActiveRecord::Migration.current_version}]"
48
+ end
49
+
50
+ def separate_database?
51
+ options[:database].present?
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+
6
+ module Pgbus
7
+ module Generators
8
+ class AddStreamStatsGenerator < Rails::Generators::Base
9
+ include ActiveRecord::Generators::Migration
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+
13
+ desc "Add stream stats table for Insights dashboard (opt-in, disabled by default)"
14
+
15
+ class_option :database,
16
+ type: :string,
17
+ default: nil,
18
+ desc: "Use a separate database for pgbus tables (e.g. --database=pgbus)"
19
+
20
+ def create_migration_file
21
+ if separate_database?
22
+ migration_template "add_stream_stats.rb.erb",
23
+ "db/pgbus_migrate/add_pgbus_stream_stats.rb"
24
+ else
25
+ migration_template "add_stream_stats.rb.erb",
26
+ "db/migrate/add_pgbus_stream_stats.rb"
27
+ end
28
+ end
29
+
30
+ def display_post_install
31
+ say ""
32
+ say "Pgbus stream stats table installed!", :green
33
+ say ""
34
+ say "Next steps:"
35
+ say " 1. Run: rails db:migrate#{":#{options[:database]}" if separate_database?}"
36
+ say " 2. Opt in by setting `config.streams_stats_enabled = true` in your"
37
+ say " pgbus initializer (disabled by default — stream event volume can"
38
+ say " be high, so stats recording is off unless you ask for it)."
39
+ say " 3. View stream insights at /pgbus/insights"
40
+ say ""
41
+ end
42
+
43
+ private
44
+
45
+ def migration_version
46
+ "[#{ActiveRecord::Migration.current_version}]"
47
+ end
48
+
49
+ def separate_database?
50
+ options[:database].present?
51
+ end
52
+ end
53
+ end
54
+ end
@@ -3,7 +3,10 @@ class AddPgbusJobStatsLatency < ActiveRecord::Migration<%= migration_version %>
3
3
  add_column :pgbus_job_stats, :enqueue_latency_ms, :bigint
4
4
  add_column :pgbus_job_stats, :retry_count, :integer, default: 0
5
5
 
6
+ # idempotent: the standalone add_job_stats_queue_index generator
7
+ # creates the same index. Either order is safe.
6
8
  add_index :pgbus_job_stats, [:queue_name, :created_at],
7
- name: "idx_pgbus_job_stats_queue_time"
9
+ name: "idx_pgbus_job_stats_queue_time",
10
+ if_not_exists: true
8
11
  end
9
12
  end
@@ -0,0 +1,11 @@
1
+ class AddPgbusJobStatsQueueIndex < ActiveRecord::Migration<%= migration_version %>
2
+ def change
3
+ # Supports InsightsController#latency_by_queue and any other
4
+ # per-queue aggregation over pgbus_job_stats. Without this, a
5
+ # 60-day window over 1M jobs/day seq-scans the whole table for
6
+ # every Insights page load with the latency tab open.
7
+ add_index :pgbus_job_stats, %i[queue_name created_at],
8
+ name: "idx_pgbus_job_stats_queue_time",
9
+ if_not_exists: true
10
+ end
11
+ end
@@ -0,0 +1,26 @@
1
+ class AddPgbusPresence < ActiveRecord::Migration<%= migration_version %>
2
+ def change
3
+ create_table :pgbus_presence_members, id: false do |t|
4
+ t.string :stream_name, null: false
5
+ t.string :member_id, null: false
6
+ t.jsonb :metadata, null: false, default: {}
7
+ t.datetime :joined_at, null: false, default: -> { "CURRENT_TIMESTAMP" }
8
+ t.datetime :last_seen_at, null: false, default: -> { "CURRENT_TIMESTAMP" }
9
+ end
10
+
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.
14
+ add_index :pgbus_presence_members,
15
+ %i[stream_name member_id],
16
+ unique: true,
17
+ name: "idx_pgbus_presence_members_pk"
18
+
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.
22
+ add_index :pgbus_presence_members,
23
+ %i[stream_name last_seen_at],
24
+ name: "idx_pgbus_presence_members_sweep"
25
+ end
26
+ end
@@ -0,0 +1,18 @@
1
+ class AddPgbusStreamStats < ActiveRecord::Migration<%= migration_version %>
2
+ def change
3
+ create_table :pgbus_stream_stats do |t|
4
+ t.string :stream_name, null: false
5
+ t.string :event_type, null: false # 'broadcast' | 'connect' | 'disconnect'
6
+ t.integer :duration_ms, null: false, default: 0
7
+ t.integer :fanout # nil for connect/disconnect
8
+ t.datetime :created_at, null: false, default: -> { "CURRENT_TIMESTAMP" }
9
+ end
10
+
11
+ add_index :pgbus_stream_stats, :created_at,
12
+ name: "idx_pgbus_stream_stats_time"
13
+ add_index :pgbus_stream_stats, %i[stream_name created_at],
14
+ name: "idx_pgbus_stream_stats_stream_time"
15
+ add_index :pgbus_stream_stats, %i[event_type created_at],
16
+ name: "idx_pgbus_stream_stats_type_time"
17
+ end
18
+ end