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.
- checksums.yaml +4 -4
- data/README.md +244 -1
- data/Rakefile +8 -1
- data/app/controllers/pgbus/insights_controller.rb +6 -0
- data/app/helpers/pgbus/streams_helper.rb +115 -0
- data/app/javascript/pgbus/stream_source_element.js +212 -0
- data/app/models/pgbus/stream_stat.rb +123 -0
- data/app/views/pgbus/insights/show.html.erb +59 -0
- data/config/locales/en.yml +16 -0
- data/config/routes.rb +11 -0
- data/lib/generators/pgbus/add_job_stats_queue_index_generator.rb +53 -0
- data/lib/generators/pgbus/add_presence_generator.rb +55 -0
- data/lib/generators/pgbus/add_stream_stats_generator.rb +54 -0
- data/lib/generators/pgbus/templates/add_job_stats_latency.rb.erb +4 -1
- data/lib/generators/pgbus/templates/add_job_stats_queue_index.rb.erb +11 -0
- data/lib/generators/pgbus/templates/add_presence.rb.erb +26 -0
- data/lib/generators/pgbus/templates/add_stream_stats.rb.erb +18 -0
- data/lib/generators/pgbus/update_generator.rb +176 -23
- data/lib/pgbus/client/ensure_stream_queue.rb +54 -0
- data/lib/pgbus/client/read_after.rb +100 -0
- data/lib/pgbus/client.rb +6 -0
- data/lib/pgbus/configuration.rb +65 -0
- data/lib/pgbus/engine.rb +31 -0
- data/lib/pgbus/generators/config_converter.rb +22 -2
- data/lib/pgbus/generators/database_target_detector.rb +94 -0
- data/lib/pgbus/generators/migration_detector.rb +217 -0
- data/lib/pgbus/process/dispatcher.rb +62 -4
- data/lib/pgbus/streams/cursor.rb +71 -0
- data/lib/pgbus/streams/envelope.rb +58 -0
- data/lib/pgbus/streams/filters.rb +98 -0
- data/lib/pgbus/streams/presence.rb +216 -0
- data/lib/pgbus/streams/signed_name.rb +69 -0
- data/lib/pgbus/streams/turbo_broadcastable.rb +53 -0
- data/lib/pgbus/streams/watermark_cache_middleware.rb +28 -0
- data/lib/pgbus/streams.rb +151 -0
- data/lib/pgbus/version.rb +1 -1
- data/lib/pgbus/web/data_source.rb +88 -10
- data/lib/pgbus/web/stream_app.rb +179 -0
- data/lib/pgbus/web/streamer/connection.rb +122 -0
- data/lib/pgbus/web/streamer/dispatcher.rb +467 -0
- data/lib/pgbus/web/streamer/heartbeat.rb +105 -0
- data/lib/pgbus/web/streamer/instance.rb +176 -0
- data/lib/pgbus/web/streamer/io_writer.rb +73 -0
- data/lib/pgbus/web/streamer/listener.rb +228 -0
- data/lib/pgbus/web/streamer/registry.rb +103 -0
- data/lib/pgbus/web/streamer.rb +53 -0
- data/lib/pgbus.rb +28 -0
- data/lib/puma/plugin/pgbus_streams.rb +54 -0
- data/lib/tasks/pgbus_streams.rake +52 -0
- 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
|
|
data/config/locales/en.yml
CHANGED
|
@@ -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
|