pgbus 0.8.1 → 0.8.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f4ffab2bc9e16f1f6dd6c268a1e1019d768c916ea397064fc66c0f4938ce6e5a
4
- data.tar.gz: ea2bfeb2bccc34b532facace227159cd0fa550d7ceb8c2ab57e48fa6a9a9020c
3
+ metadata.gz: a68e87de88b53f6f1b421479e766cbb82b7e6a9fc7a4257cd11cd52ef560f1a7
4
+ data.tar.gz: d8b0f9aeb5f7cd9ff4f14db915d27066918b8fb66e8be80f1710ca07edff02ce
5
5
  SHA512:
6
- metadata.gz: cbce8e9eb0243c5875f4d39ce55dd47b9eda9043959dbc833bd3a2ac7e1470bdb299d042e2394b747c59be45177ff9fdb349f1126f360e70a78b1c03dd2db90f
7
- data.tar.gz: 6d46db3bd53e2f230a43fb89496f744976910a2f64540fbfde956e0d5661a808fcfc586a6a552fe114e6b716ff288e7d310651eb2f05ef381ee058e9d0180e60
6
+ metadata.gz: 77afe26722276232120c9e0d0b41a3c3e849ac0f86c874500594db675a96b5571a7ec6a9edc30896883162d985f7081236c602f232896a04eafd660b20970b25
7
+ data.tar.gz: 8bd975ab73750cf7d22e99fcc672b86a85f125bf869f35672eb5675257d899a1526c196953f7ab4fe76c1b2a34383524ff105f63ad4bc82f79ccaf8f24c40e2e
@@ -15,6 +15,7 @@ module Pgbus
15
15
  payload[:latency_trend] = data_source.latency_trend(minutes: minutes)
16
16
  payload[:latency_by_queue] = data_source.latency_by_queue(minutes: minutes)
17
17
  end
18
+ payload[:live_streams] = data_source.live_stream_metrics
18
19
  render json: payload
19
20
  end
20
21
  end
@@ -9,6 +9,8 @@ module Pgbus
9
9
  @latency_by_queue = data_source.latency_by_queue(minutes: @minutes)
10
10
  @latency_available = Pgbus::JobStat.latency_columns?
11
11
 
12
+ @live_stream_metrics = data_source.live_stream_metrics
13
+
12
14
  @stream_stats_available = data_source.stream_stats_available?
13
15
  return unless @stream_stats_available
14
16
 
@@ -159,6 +159,61 @@
159
159
  </table>
160
160
  </div>
161
161
 
162
+ <% if @live_stream_metrics[:totals][:streams] > 0 %>
163
+ <!-- Live stream counters (in-memory, always-on) -->
164
+ <div class="mt-8">
165
+ <h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4"><%= t("pgbus.insights.show.live_streams.title") %></h2>
166
+
167
+ <div class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 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.live_streams.broadcasts") %></dt>
170
+ <dd class="mt-1 text-2xl font-semibold text-gray-900 dark:text-white"><%= pgbus_number(@live_stream_metrics[:totals][: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.live_streams.active_connections") %></dt>
174
+ <dd class="mt-1 text-2xl font-semibold text-green-600 dark:text-green-400"><%= pgbus_number(@live_stream_metrics[:totals][:active_connections]) %></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.live_streams.total_connections") %></dt>
178
+ <dd class="mt-1 text-2xl font-semibold text-gray-900 dark:text-white"><%= pgbus_number(@live_stream_metrics[:totals][:total_connections]) %></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.live_streams.streams") %></dt>
182
+ <dd class="mt-1 text-2xl font-semibold text-gray-900 dark:text-white"><%= pgbus_number(@live_stream_metrics[:totals][:streams]) %></dd>
183
+ </div>
184
+ </div>
185
+
186
+ <div class="rounded-lg bg-white dark:bg-gray-800 shadow ring-1 ring-gray-200 dark:ring-gray-700">
187
+ <div class="px-5 py-4 border-b border-gray-200 dark:border-gray-700">
188
+ <h3 class="text-sm font-medium text-gray-700 dark:text-gray-300"><%= t("pgbus.insights.show.live_streams.per_stream_title") %></h3>
189
+ </div>
190
+ <table class="pgbus-table min-w-full divide-y divide-gray-200 dark:divide-gray-700">
191
+ <thead class="bg-gray-50 dark:bg-gray-900">
192
+ <tr>
193
+ <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500 dark:text-gray-400"><%= t("pgbus.insights.show.live_streams.stream") %></th>
194
+ <th class="px-4 py-3 text-right text-xs font-medium uppercase text-gray-500 dark:text-gray-400"><%= t("pgbus.insights.show.live_streams.broadcasts") %></th>
195
+ <th class="px-4 py-3 text-right text-xs font-medium uppercase text-gray-500 dark:text-gray-400"><%= t("pgbus.insights.show.live_streams.active_connections") %></th>
196
+ <th class="px-4 py-3 text-right text-xs font-medium uppercase text-gray-500 dark:text-gray-400"><%= t("pgbus.insights.show.live_streams.total_connections") %></th>
197
+ </tr>
198
+ </thead>
199
+ <tbody class="divide-y divide-gray-100 dark:divide-gray-700">
200
+ <% @live_stream_metrics[:streams].sort_by { |_name, data| -data[:broadcasts] }.each do |name, data| %>
201
+ <tr class="hover:bg-gray-50 dark:hover:bg-gray-700/50">
202
+ <td data-label="Stream" class="px-4 py-3 text-sm font-medium text-gray-700 dark:text-gray-300"><%= name %></td>
203
+ <td data-label="Broadcasts" class="px-4 py-3 text-sm text-right font-mono text-gray-700 dark:text-gray-300"><%= pgbus_number(data[:broadcasts]) %></td>
204
+ <td data-label="Active" class="px-4 py-3 text-sm text-right font-mono text-gray-700 dark:text-gray-300"><%= pgbus_number(data[:active_connections]) %></td>
205
+ <td data-label="Total" class="px-4 py-3 text-sm text-right font-mono text-gray-700 dark:text-gray-300"><%= pgbus_number(data[:total_connections]) %></td>
206
+ </tr>
207
+ <% end %>
208
+ <% if @live_stream_metrics[:streams].empty? %>
209
+ <tr><td colspan="4" class="px-4 py-8 text-center text-sm text-gray-400 dark:text-gray-500"><%= t("pgbus.insights.show.live_streams.empty") %></td></tr>
210
+ <% end %>
211
+ </tbody>
212
+ </table>
213
+ </div>
214
+ </div>
215
+ <% end %>
216
+
162
217
  <% if @stream_stats_available %>
163
218
  <!-- Stream stats -->
164
219
  <div class="mt-8">
@@ -222,6 +222,15 @@ da:
222
222
  p95: P95 (ms)
223
223
  queue: Kø
224
224
  title: Forsinkelse efter kø
225
+ live_streams:
226
+ active_connections: Aktive forbindelser
227
+ broadcasts: Samlede udsendelser
228
+ empty: Ingen aktive streams i denne proces
229
+ per_stream_title: Per-stream tællere
230
+ stream: Stream
231
+ streams: Streams
232
+ title: Live stream-tællere
233
+ total_connections: Samlede forbindelser
225
234
  slowest:
226
235
  empty: Ingen jobstatistikker endnu
227
236
  headers:
@@ -222,6 +222,15 @@ de:
222
222
  p95: P95 (ms)
223
223
  queue: Warteschlange
224
224
  title: Latenz nach Warteschlange
225
+ live_streams:
226
+ active_connections: Aktive Verbindungen
227
+ broadcasts: Gesamt-Broadcasts
228
+ empty: Keine aktiven Streams in diesem Prozess
229
+ per_stream_title: Pro-Stream-Zähler
230
+ stream: Stream
231
+ streams: Streams
232
+ title: Live-Stream-Zähler
233
+ total_connections: Gesamtverbindungen
225
234
  slowest:
226
235
  empty: Noch keine Auftragsstatistiken
227
236
  headers:
@@ -222,6 +222,15 @@ en:
222
222
  p95: P95 (ms)
223
223
  queue: Queue
224
224
  title: Latency by Queue
225
+ live_streams:
226
+ active_connections: Active Connections
227
+ broadcasts: Total Broadcasts
228
+ empty: No streams active in this process
229
+ per_stream_title: Per-Stream Counters
230
+ stream: Stream
231
+ streams: Streams
232
+ title: Live Stream Counters
233
+ total_connections: Total Connections
225
234
  slowest:
226
235
  empty: No job stats yet
227
236
  headers:
@@ -222,6 +222,15 @@ es:
222
222
  p95: P95 (ms)
223
223
  queue: Cola
224
224
  title: Latencia por Cola
225
+ live_streams:
226
+ active_connections: Conexiones activas
227
+ broadcasts: Emisiones totales
228
+ empty: No hay streams activos en este proceso
229
+ per_stream_title: Contadores por stream
230
+ stream: Stream
231
+ streams: Streams
232
+ title: Contadores de streams en vivo
233
+ total_connections: Conexiones totales
225
234
  slowest:
226
235
  empty: Aún no hay estadísticas de trabajos
227
236
  headers:
@@ -222,6 +222,15 @@ fi:
222
222
  p95: P95 (ms)
223
223
  queue: Jono
224
224
  title: Viive jonon mukaan
225
+ live_streams:
226
+ active_connections: Aktiiviset yhteydet
227
+ broadcasts: Lähetykset yhteensä
228
+ empty: Ei aktiivisia striimejä tässä prosessissa
229
+ per_stream_title: Striimikohtaiset laskurit
230
+ stream: Striimi
231
+ streams: Striimit
232
+ title: Reaaliaikaiset striimilaskurit
233
+ total_connections: Yhteydet yhteensä
225
234
  slowest:
226
235
  empty: Ei vielä tehtävätilastoja
227
236
  headers:
@@ -222,6 +222,15 @@ fr:
222
222
  p95: P95 (ms)
223
223
  queue: File d'attente
224
224
  title: Latence par file d'attente
225
+ live_streams:
226
+ active_connections: Connexions actives
227
+ broadcasts: Diffusions totales
228
+ empty: Aucun flux actif dans ce processus
229
+ per_stream_title: Compteurs par flux
230
+ stream: Flux
231
+ streams: Flux
232
+ title: Compteurs de flux en direct
233
+ total_connections: Connexions totales
225
234
  slowest:
226
235
  empty: Pas encore de statistiques de tâches
227
236
  headers:
@@ -222,6 +222,15 @@ it:
222
222
  p95: P95 (ms)
223
223
  queue: Coda
224
224
  title: Latenza per coda
225
+ live_streams:
226
+ active_connections: Connessioni attive
227
+ broadcasts: Trasmissioni totali
228
+ empty: Nessuno stream attivo in questo processo
229
+ per_stream_title: Contatori per stream
230
+ stream: Stream
231
+ streams: Stream
232
+ title: Contatori stream in tempo reale
233
+ total_connections: Connessioni totali
225
234
  slowest:
226
235
  empty: Nessuna statistica lavori ancora
227
236
  headers:
@@ -222,6 +222,15 @@ ja:
222
222
  p95: P95 (ms)
223
223
  queue: キュー
224
224
  title: キュー別遅延
225
+ live_streams:
226
+ active_connections: アクティブ接続
227
+ broadcasts: 総ブロードキャスト
228
+ empty: このプロセスにアクティブなストリームはありません
229
+ per_stream_title: ストリーム別カウンター
230
+ stream: ストリーム
231
+ streams: ストリーム
232
+ title: ライブストリームカウンター
233
+ total_connections: 総接続数
225
234
  slowest:
226
235
  empty: まだジョブ統計がありません
227
236
  headers:
@@ -222,6 +222,15 @@ nb:
222
222
  p95: P95 (ms)
223
223
  queue: Kø
224
224
  title: Forsinkelse per kø
225
+ live_streams:
226
+ active_connections: Aktive tilkoblinger
227
+ broadcasts: Totale kringkastinger
228
+ empty: Ingen aktive strømmer i denne prosessen
229
+ per_stream_title: Per-strøm tellere
230
+ stream: Strøm
231
+ streams: Strømmer
232
+ title: Sanntids strømtellere
233
+ total_connections: Totale tilkoblinger
225
234
  slowest:
226
235
  empty: Ingen jobbstatistikk ennå
227
236
  headers:
@@ -222,6 +222,15 @@ nl:
222
222
  p95: P95 (ms)
223
223
  queue: Wachtrij
224
224
  title: Vertraging per wachtrij
225
+ live_streams:
226
+ active_connections: Actieve verbindingen
227
+ broadcasts: Totaal uitzendingen
228
+ empty: Geen actieve streams in dit proces
229
+ per_stream_title: Per-stream tellers
230
+ stream: Stream
231
+ streams: Streams
232
+ title: Live stream-tellers
233
+ total_connections: Totale verbindingen
225
234
  slowest:
226
235
  empty: Nog geen taakstatistieken
227
236
  headers:
@@ -222,6 +222,15 @@ pt:
222
222
  p95: P95 (ms)
223
223
  queue: Fila
224
224
  title: Latência por Fila
225
+ live_streams:
226
+ active_connections: Conexões ativas
227
+ broadcasts: Transmissões totais
228
+ empty: Nenhum stream ativo neste processo
229
+ per_stream_title: Contadores por stream
230
+ stream: Stream
231
+ streams: Streams
232
+ title: Contadores de stream ao vivo
233
+ total_connections: Conexões totais
225
234
  slowest:
226
235
  empty: Nenhuma estatística de tarefa ainda
227
236
  headers:
@@ -222,6 +222,15 @@ sv:
222
222
  p95: P95 (ms)
223
223
  queue: Kö
224
224
  title: Fördröjning per kö
225
+ live_streams:
226
+ active_connections: Aktiva anslutningar
227
+ broadcasts: Totala sändningar
228
+ empty: Inga aktiva strömmar i denna process
229
+ per_stream_title: Per-ström mätare
230
+ stream: Ström
231
+ streams: Strömmar
232
+ title: Realtids strömmätare
233
+ total_connections: Totala anslutningar
225
234
  slowest:
226
235
  empty: Inga jobbstatistik än
227
236
  headers:
@@ -25,9 +25,11 @@ module Pgbus
25
25
  json = payload.is_a?(String) ? payload : JSON.generate(payload)
26
26
 
27
27
  Instrumentation.instrument("pgbus.stream.notify", stream: stream_name, bytes: json.bytesize) do
28
- synchronized do
29
- @pgmq.__send__(:with_connection) do |conn|
30
- conn.exec_params("SELECT pg_notify($1, $2)", [channel, json])
28
+ with_stale_connection_retry do
29
+ synchronized do
30
+ @pgmq.__send__(:with_connection) do |conn|
31
+ conn.exec_params("SELECT pg_notify($1, $2)", [channel, json])
32
+ end
31
33
  end
32
34
  end
33
35
  end
data/lib/pgbus/engine.rb CHANGED
@@ -136,6 +136,11 @@ module Pgbus
136
136
  _autoload_trigger = Pgbus::Streams::TurboBroadcastable
137
137
  Pgbus::Streams.install_turbo_broadcastable_patch!
138
138
 
139
+ if defined?(::Turbo::Broadcastable)
140
+ _autoload_trigger_broadcastable = Pgbus::Streams::BroadcastableOverride
141
+ Pgbus::Streams::BroadcastableOverride.install!(::Turbo::Broadcastable)
142
+ end
143
+
139
144
  # Subscribe-side patch: override turbo_stream_from to render
140
145
  # <pgbus-stream-source> (SSE) instead of <turbo-cable-stream-source>
141
146
  # (ActionCable). Without this, third-party gems like
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module Streams
5
+ # Runtime patch for `Turbo::Broadcastable` that adds `durable:` kwarg
6
+ # support to synchronous broadcast helpers. Applied at Rails engine boot
7
+ # time when `defined?(::Turbo::Broadcastable)` — see `Pgbus::Engine`.
8
+ #
9
+ # Instance methods extract `durable:` from kwargs and set a thread-local
10
+ # (`Thread.current[:pgbus_broadcast_durable]`) that
11
+ # `TurboBroadcastable#broadcast_stream_to` reads. The thread-local is
12
+ # always cleaned up after the broadcast, even on error.
13
+ #
14
+ # The `_later_to` variants are NOT overridden because turbo-rails
15
+ # enqueues them as background jobs — the thread-local cannot survive
16
+ # into the job execution context. For async broadcasts, use
17
+ # `streams_durable_patterns` or `streams_default_broadcast_mode` config.
18
+ #
19
+ # Class methods (`broadcasts_to`, `broadcasts_refreshes_to`) accept
20
+ # `durable:` and store it so the generated callbacks set the thread-local
21
+ # before each broadcast.
22
+ module BroadcastableOverride
23
+ BROADCAST_METHODS = %i[
24
+ broadcast_after_to
25
+ broadcast_before_to
26
+ broadcast_replace_to
27
+ broadcast_append_to
28
+ broadcast_prepend_to
29
+ broadcast_update_to
30
+ broadcast_remove_to
31
+ broadcast_refresh_to
32
+ broadcast_render_to
33
+ ].freeze
34
+
35
+ BROADCAST_ACTION_METHODS = %i[
36
+ broadcast_action_to
37
+ ].freeze
38
+
39
+ BROADCAST_METHODS.each do |method_name|
40
+ define_method(method_name) do |*streamables, **kwargs|
41
+ durable = kwargs.delete(:durable)
42
+ with_pgbus_durable(durable) { super(*streamables, **kwargs) }
43
+ end
44
+ end
45
+
46
+ BROADCAST_ACTION_METHODS.each do |method_name|
47
+ define_method(method_name) do |*streamables, action:, **kwargs|
48
+ durable = kwargs.delete(:durable)
49
+ with_pgbus_durable(durable) { super(*streamables, action: action, **kwargs) }
50
+ end
51
+ end
52
+
53
+ module ClassMethods
54
+ def broadcasts_to(stream, durable: nil, inserts_by: :append, target: broadcast_target_default, **rendering)
55
+ if durable.nil?
56
+ after_create_commit lambda {
57
+ broadcast_action_later_to(
58
+ stream.try(:call, self) || send(stream),
59
+ action: inserts_by,
60
+ target: target.try(:call, self) || target,
61
+ **rendering
62
+ )
63
+ }
64
+ after_update_commit -> { broadcast_replace_later_to(stream.try(:call, self) || send(stream), **rendering) }
65
+ after_destroy_commit -> { broadcast_remove_to(stream.try(:call, self) || send(stream)) }
66
+ else
67
+ @pgbus_durable_streams ||= {}
68
+ @pgbus_durable_streams[stream] = durable
69
+
70
+ after_create_commit lambda {
71
+ broadcast_action_to(
72
+ stream.try(:call, self) || send(stream),
73
+ action: inserts_by,
74
+ target: target.try(:call, self) || target,
75
+ durable: durable,
76
+ **rendering
77
+ )
78
+ }
79
+ after_update_commit lambda {
80
+ broadcast_replace_to(stream.try(:call, self) || send(stream), durable: durable, **rendering)
81
+ }
82
+ after_destroy_commit lambda {
83
+ broadcast_remove_to(stream.try(:call, self) || send(stream), durable: durable)
84
+ }
85
+ end
86
+ end
87
+
88
+ def broadcasts_refreshes_to(stream, durable: nil)
89
+ if durable.nil?
90
+ after_commit -> { broadcast_refresh_later_to(stream.try(:call, self) || send(stream)) }
91
+ else
92
+ @pgbus_durable_streams ||= {}
93
+ @pgbus_durable_streams[stream] = durable
94
+
95
+ after_commit lambda {
96
+ broadcast_refresh_to(stream.try(:call, self) || send(stream), durable: durable)
97
+ }
98
+ end
99
+ end
100
+
101
+ def pgbus_durable_streams
102
+ @pgbus_durable_streams || {}
103
+ end
104
+ end
105
+
106
+ def self.install!(mod)
107
+ return if mod.ancestors.include?(self)
108
+
109
+ mod.prepend(self)
110
+
111
+ # Turbo::Broadcastable uses ActiveSupport::Concern, which extends
112
+ # each including class with Turbo::Broadcastable::ClassMethods.
113
+ # Prepending our ClassMethods onto that nested module ensures any
114
+ # class that includes Turbo::Broadcastable picks up our overrides
115
+ # (broadcasts_to, broadcasts_refreshes_to) automatically.
116
+ if defined?(::Turbo::Broadcastable::ClassMethods) &&
117
+ !::Turbo::Broadcastable::ClassMethods.ancestors.include?(ClassMethods)
118
+ ::Turbo::Broadcastable::ClassMethods.prepend(ClassMethods)
119
+ end
120
+ end
121
+
122
+ private
123
+
124
+ def with_pgbus_durable(value)
125
+ return yield if value.nil?
126
+
127
+ previous = Thread.current[:pgbus_broadcast_durable]
128
+ Thread.current[:pgbus_broadcast_durable] = value
129
+ yield
130
+ ensure
131
+ Thread.current[:pgbus_broadcast_durable] = previous unless value.nil?
132
+ end
133
+ end
134
+ end
135
+ end
@@ -36,8 +36,13 @@ module Pgbus
36
36
  module TurboBroadcastable
37
37
  def broadcast_stream_to(*streamables, content:)
38
38
  name = stream_name_from(streamables)
39
- mode = Pgbus.configuration.streams_default_broadcast_mode
40
- Pgbus.stream(name, durable: mode == :durable).broadcast(content)
39
+ override = Thread.current[:pgbus_broadcast_durable]
40
+ durable = if override.nil?
41
+ Pgbus.configuration.streams_default_broadcast_mode == :durable
42
+ else
43
+ override
44
+ end
45
+ Pgbus.stream(name, durable: durable).broadcast(content)
41
46
  end
42
47
  end
43
48
 
data/lib/pgbus/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pgbus
4
- VERSION = "0.8.1"
4
+ VERSION = "0.8.3"
5
5
  end
@@ -681,6 +681,19 @@ module Pgbus
681
681
  # true AND the migration has been run. Controllers should gate
682
682
  # rendering on `stream_stats_available?` to avoid showing empty
683
683
  # sections.
684
+ def live_stream_metrics
685
+ counter = Pgbus::Web::Streamer.stream_counter
686
+ unless counter
687
+ empty_totals = { broadcasts: 0, active_connections: 0, total_connections: 0, streams: 0 }
688
+ return { streams: {}, totals: empty_totals }
689
+ end
690
+
691
+ { streams: counter.snapshot, totals: counter.totals }
692
+ rescue StandardError => e
693
+ Pgbus.logger.debug { "[Pgbus::Web] Error fetching live stream metrics: #{e.message}" }
694
+ { streams: {}, totals: { broadcasts: 0, active_connections: 0, total_connections: 0, streams: 0 } }
695
+ end
696
+
684
697
  def stream_stats_available?
685
698
  Pgbus.configuration.streams_stats_enabled && StreamStat.table_exists?
686
699
  rescue StandardError => e
@@ -38,7 +38,8 @@ module Pgbus
38
38
  def enqueue(envelopes)
39
39
  written = []
40
40
  envelopes.each do |envelope|
41
- next if envelope.msg_id <= @last_msg_id_sent
41
+ ephemeral = envelope.msg_id.negative?
42
+ next if !ephemeral && envelope.msg_id <= @last_msg_id_sent
42
43
 
43
44
  bytes = Pgbus::Streams::Envelope.message(
44
45
  id: envelope.msg_id,
@@ -48,7 +49,7 @@ module Pgbus
48
49
 
49
50
  result = @writer.write(self, bytes, deadline_ms: @write_deadline_ms)
50
51
  if result == :ok
51
- @last_msg_id_sent = envelope.msg_id
52
+ @last_msg_id_sent = envelope.msg_id unless ephemeral
52
53
  @last_write_at = monotonic
53
54
  written << envelope
54
55
  else
@@ -20,7 +20,7 @@ module Pgbus
20
20
  # production the module-level Streamer.current(...) builds all of the
21
21
  # defaults from the configuration.
22
22
  class Instance
23
- attr_reader :registry, :listener, :dispatcher, :heartbeat, :dispatch_queue
23
+ attr_reader :registry, :listener, :dispatcher, :heartbeat, :dispatch_queue, :stream_counter
24
24
 
25
25
  def initialize(
26
26
  client: Pgbus.client,
@@ -36,6 +36,7 @@ module Pgbus
36
36
  @registry = registry || Registry.new
37
37
  @dispatch_queue = dispatch_queue || Queue.new
38
38
 
39
+ @stream_counter = StreamCounter.new
39
40
  @pg_connection = pg_connection || build_pg_connection
40
41
  @listener = Listener.new(
41
42
  pg_connection: @pg_connection,
@@ -49,7 +50,8 @@ module Pgbus
49
50
  listener: @listener,
50
51
  dispatch_queue: @dispatch_queue,
51
52
  logger: @logger,
52
- config: @config
53
+ config: @config,
54
+ stream_counter: @stream_counter
53
55
  )
54
56
  @heartbeat = Heartbeat.new(
55
57
  registry: @registry,
@@ -37,16 +37,23 @@ module Pgbus
37
37
  end
38
38
  end
39
39
 
40
+ # Returns true if a matching connection was removed, false if it
41
+ # was not registered. Callers (e.g. StreamEventDispatcher) use the
42
+ # return value to make per-connection bookkeeping idempotent on
43
+ # duplicate DisconnectMessages — prune_dead can enqueue a
44
+ # DisconnectMessage on every wake while the first one is still
45
+ # waiting in the queue.
40
46
  def unregister(connection)
41
47
  @mutex.synchronize do
42
48
  existing = @by_id.delete(connection.id)
43
- return unless existing
49
+ return false unless existing
44
50
 
45
51
  set = @by_stream[existing.stream_name]
46
- next unless set
47
-
48
- set.delete(existing)
49
- @by_stream.delete(existing.stream_name) if set.empty?
52
+ if set
53
+ set.delete(existing)
54
+ @by_stream.delete(existing.stream_name) if set.empty?
55
+ end
56
+ true
50
57
  end
51
58
  end
52
59
 
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent"
4
+
5
+ module Pgbus
6
+ module Web
7
+ module Streamer
8
+ class StreamCounter
9
+ def initialize
10
+ @streams = Concurrent::Map.new
11
+ end
12
+
13
+ def increment_broadcasts(stream_name)
14
+ counters_for(stream_name)[:broadcasts].increment
15
+ end
16
+
17
+ def increment_connections(stream_name)
18
+ counters_for(stream_name)[:active_connections].increment
19
+ end
20
+
21
+ def decrement_connections(stream_name)
22
+ counters_for(stream_name)[:active_connections].decrement
23
+ end
24
+
25
+ def increment_total_connections(stream_name)
26
+ counters_for(stream_name)[:total_connections].increment
27
+ end
28
+
29
+ def broadcasts(stream_name)
30
+ entry = @streams[stream_name]
31
+ entry ? entry[:broadcasts].value : 0
32
+ end
33
+
34
+ def active_connections(stream_name)
35
+ entry = @streams[stream_name]
36
+ return 0 unless entry
37
+
38
+ [entry[:active_connections].value, 0].max
39
+ end
40
+
41
+ def total_connections(stream_name)
42
+ entry = @streams[stream_name]
43
+ entry ? entry[:total_connections].value : 0
44
+ end
45
+
46
+ def snapshot
47
+ result = {}
48
+ @streams.each_pair do |name, counters|
49
+ result[name] = {
50
+ broadcasts: counters[:broadcasts].value,
51
+ active_connections: [counters[:active_connections].value, 0].max,
52
+ total_connections: counters[:total_connections].value
53
+ }
54
+ end
55
+ result
56
+ end
57
+
58
+ def totals
59
+ total_broadcasts = 0
60
+ total_active = 0
61
+ total_conns = 0
62
+ stream_count = 0
63
+
64
+ @streams.each_pair do |_name, counters|
65
+ total_broadcasts += counters[:broadcasts].value
66
+ total_active += [counters[:active_connections].value, 0].max
67
+ total_conns += counters[:total_connections].value
68
+ stream_count += 1
69
+ end
70
+
71
+ {
72
+ broadcasts: total_broadcasts,
73
+ active_connections: total_active,
74
+ total_connections: total_conns,
75
+ streams: stream_count
76
+ }
77
+ end
78
+
79
+ private
80
+
81
+ def counters_for(stream_name)
82
+ @streams.compute_if_absent(stream_name) do
83
+ {
84
+ broadcasts: Concurrent::AtomicFixnum.new(0),
85
+ active_connections: Concurrent::AtomicFixnum.new(0),
86
+ total_connections: Concurrent::AtomicFixnum.new(0)
87
+ }
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -47,9 +47,11 @@ module Pgbus
47
47
 
48
48
  DEFAULT_READ_LIMIT = 500
49
49
 
50
+ attr_reader :stream_counter
51
+
50
52
  def initialize(client:, registry:, listener:, dispatch_queue:,
51
53
  logger: Pgbus.logger, read_limit: DEFAULT_READ_LIMIT,
52
- filters: nil, config: nil)
54
+ filters: nil, config: nil, stream_counter: nil)
53
55
  @client = client
54
56
  @registry = registry
55
57
  @listener = listener
@@ -67,6 +69,7 @@ module Pgbus
67
69
  # process-wide setting. Falls back to the global config
68
70
  # for production call sites that don't specify one.
69
71
  @config = config || Pgbus.configuration
72
+ @stream_counter = stream_counter || StreamCounter.new
70
73
  # stream_name → Array<[connection, Array<Envelope>]>
71
74
  @in_flight = Hash.new { |h, k| h[k] = [] }
72
75
  # PGMQ full table name (pgbus_<prefix>_<name>) → logical stream
@@ -128,21 +131,16 @@ module Pgbus
128
131
  msg = @queue.pop
129
132
  break if msg == :__stop__
130
133
 
131
- # Wake coalescing: if a WakeMessage arrives, opportunistically
132
- # drain consecutive same-stream wakes from the queue. Without
133
- # this, N broadcasts in rapid succession produce N
134
- # WakeMessages, each running its own read_after roundtrip
135
- # even though one read_after with the lowest cursor would
136
- # have pulled all N messages. The drain is bounded by the
137
- # queue's current contents — once we hit a non-Wake or a
138
- # different stream, we stop and let the regular path handle
139
- # the rest.
140
- if msg.is_a?(WakeMessage) && msg.payload.nil?
141
- wakes, trailing = drain_wakes_for(msg)
142
- wakes.each { |w| handle(w) }
143
- handle(trailing) if trailing
144
- else
145
- handle(msg)
134
+ begin
135
+ if msg.is_a?(WakeMessage) && msg.payload.nil?
136
+ wakes, trailing = drain_wakes_for(msg)
137
+ wakes.each { |w| handle(w) }
138
+ handle(trailing) if trailing
139
+ else
140
+ handle(msg)
141
+ end
142
+ ensure
143
+ release_ar_connections
146
144
  end
147
145
  end
148
146
  rescue StandardError => e
@@ -224,6 +222,7 @@ module Pgbus
224
222
  end
225
223
 
226
224
  prune_dead(registered)
225
+ @stream_counter.increment_broadcasts(stream)
227
226
 
228
227
  record_stat(
229
228
  stream_name: stream,
@@ -258,12 +257,14 @@ module Pgbus
258
257
  end
259
258
 
260
259
  prune_dead(registered)
260
+ @stream_counter.increment_broadcasts(stream)
261
261
 
262
262
  record_stat(
263
263
  stream_name: stream,
264
264
  event_type: "broadcast",
265
265
  started_at: started_at,
266
- fanout: registered.size + in_flight_pairs.size
266
+ fanout: registered.size + in_flight_pairs.size,
267
+ ephemeral: true
267
268
  )
268
269
  end
269
270
 
@@ -312,18 +313,15 @@ module Pgbus
312
313
  # here. Otherwise this stream's state is pinned for the
313
314
  # life of the worker.
314
315
  remove_in_flight(stream, connection)
316
+ @stream_counter.increment_total_connections(stream)
315
317
  if connection.dead?
316
318
  @scanned_cursor.delete(connection)
317
319
  cleanup_stream_if_unused(stream)
318
320
  else
321
+ @stream_counter.increment_connections(stream)
319
322
  @registry.register(connection)
320
323
  end
321
324
 
322
- # Record the connect regardless of whether the connection
323
- # survived the replay — a dead-before-register is still an
324
- # operator-visible "connection attempt" and disconnects
325
- # won't be recorded for it, so dropping it here would
326
- # under-count.
327
325
  record_stat(
328
326
  stream_name: stream,
329
327
  event_type: "connect",
@@ -345,8 +343,9 @@ module Pgbus
345
343
  started_at = monotonic_ms
346
344
  connection = msg.connection
347
345
  stream = connection.stream_name
348
- @registry.unregister(connection)
346
+ removed = @registry.unregister(connection)
349
347
  @scanned_cursor.delete(connection)
348
+ @stream_counter.decrement_connections(stream) if removed
350
349
  cleanup_stream_if_unused(stream)
351
350
 
352
351
  record_stat(
@@ -479,8 +478,21 @@ module Pgbus
479
478
  # if operators actually look at it. All failures are
480
479
  # swallowed by StreamStat.record! itself so a stats-table
481
480
  # outage cannot block the dispatcher.
482
- def record_stat(stream_name:, event_type:, started_at:, fanout: nil)
483
- return unless @config.streams_stats_enabled
481
+ # Release any AR connections the dispatcher fiber acquired during
482
+ # this iteration (typically from StreamStat.record! via BusRecord).
483
+ # Without this, the connection stays leased while the fiber parks
484
+ # on @queue.pop, blocking clear_reloadable_connections! on the
485
+ # next Rails code reload (10s wedge under rack-timeout).
486
+ def release_ar_connections
487
+ return unless defined?(::ActiveRecord::Base)
488
+
489
+ Pgbus::BusRecord.connection_handler.clear_active_connections!
490
+ rescue StandardError => e
491
+ @logger.debug { "[Pgbus::Streamer::StreamEventDispatcher] AR connection release failed: #{e.class}: #{e.message}" }
492
+ end
493
+
494
+ def record_stat(stream_name:, event_type:, started_at:, fanout: nil, ephemeral: false)
495
+ return unless ephemeral || @config.streams_stats_enabled
484
496
 
485
497
  Pgbus::StreamStat.record!(
486
498
  stream_name: stream_name,
@@ -37,8 +37,10 @@ module Pgbus
37
37
  @current_mutex.synchronize { @current = instance }
38
38
  end
39
39
 
40
- # Tear down the current instance and clear the slot. Called by the
41
- # Puma shutdown hook (Phase 4.4) and by tests between examples.
40
+ def stream_counter
41
+ @current_mutex.synchronize { @current&.stream_counter }
42
+ end
43
+
42
44
  def reset!
43
45
  instance = nil
44
46
  @current_mutex.synchronize do
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.1
4
+ version: 0.8.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson
@@ -308,6 +308,7 @@ files:
308
308
  - lib/pgbus/serializer.rb
309
309
  - lib/pgbus/stat_buffer.rb
310
310
  - lib/pgbus/streams.rb
311
+ - lib/pgbus/streams/broadcastable_override.rb
311
312
  - lib/pgbus/streams/cursor.rb
312
313
  - lib/pgbus/streams/envelope.rb
313
314
  - lib/pgbus/streams/filters.rb
@@ -337,6 +338,7 @@ files:
337
338
  - lib/pgbus/web/streamer/io_writer.rb
338
339
  - lib/pgbus/web/streamer/listener.rb
339
340
  - lib/pgbus/web/streamer/registry.rb
341
+ - lib/pgbus/web/streamer/stream_counter.rb
340
342
  - lib/pgbus/web/streamer/stream_event_dispatcher.rb
341
343
  - lib/puma/plugin/pgbus_streams.rb
342
344
  - lib/tasks/pgbus_autovacuum.rake