pgbus 0.8.1 → 0.8.2

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: 479168f8521b550bb2b786766ac630374cb80de0afb9e2e26afe87a9585b466c
4
+ data.tar.gz: a6c28faae035eebd4eb925d66b2914e50112728b648eaefc79473a5ff8c1513c
5
5
  SHA512:
6
- metadata.gz: cbce8e9eb0243c5875f4d39ce55dd47b9eda9043959dbc833bd3a2ac7e1470bdb299d042e2394b747c59be45177ff9fdb349f1126f360e70a78b1c03dd2db90f
7
- data.tar.gz: 6d46db3bd53e2f230a43fb89496f744976910a2f64540fbfde956e0d5661a808fcfc586a6a552fe114e6b716ff288e7d310651eb2f05ef381ee058e9d0180e60
6
+ metadata.gz: 8c8dc58764091199f12b11befacf8f218038e2f09226350b52c625ea953751a25d39d3c28e74088fb9df19fe0f7b87a055b16cd380a5dae7618198ab35dda430
7
+ data.tar.gz: 5a76fa6870bd3b207da626b4a8e5317d38470e4d2447cfbb20fa01faafe88eb23156d5a3ecb0ef309b758f41a8222ee8c80fe38ec66599f5239c951b4de82325
@@ -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.2"
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
@@ -224,6 +227,7 @@ module Pgbus
224
227
  end
225
228
 
226
229
  prune_dead(registered)
230
+ @stream_counter.increment_broadcasts(stream)
227
231
 
228
232
  record_stat(
229
233
  stream_name: stream,
@@ -258,12 +262,14 @@ module Pgbus
258
262
  end
259
263
 
260
264
  prune_dead(registered)
265
+ @stream_counter.increment_broadcasts(stream)
261
266
 
262
267
  record_stat(
263
268
  stream_name: stream,
264
269
  event_type: "broadcast",
265
270
  started_at: started_at,
266
- fanout: registered.size + in_flight_pairs.size
271
+ fanout: registered.size + in_flight_pairs.size,
272
+ ephemeral: true
267
273
  )
268
274
  end
269
275
 
@@ -312,18 +318,15 @@ module Pgbus
312
318
  # here. Otherwise this stream's state is pinned for the
313
319
  # life of the worker.
314
320
  remove_in_flight(stream, connection)
321
+ @stream_counter.increment_total_connections(stream)
315
322
  if connection.dead?
316
323
  @scanned_cursor.delete(connection)
317
324
  cleanup_stream_if_unused(stream)
318
325
  else
326
+ @stream_counter.increment_connections(stream)
319
327
  @registry.register(connection)
320
328
  end
321
329
 
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
330
  record_stat(
328
331
  stream_name: stream,
329
332
  event_type: "connect",
@@ -345,8 +348,9 @@ module Pgbus
345
348
  started_at = monotonic_ms
346
349
  connection = msg.connection
347
350
  stream = connection.stream_name
348
- @registry.unregister(connection)
351
+ removed = @registry.unregister(connection)
349
352
  @scanned_cursor.delete(connection)
353
+ @stream_counter.decrement_connections(stream) if removed
350
354
  cleanup_stream_if_unused(stream)
351
355
 
352
356
  record_stat(
@@ -479,8 +483,8 @@ module Pgbus
479
483
  # if operators actually look at it. All failures are
480
484
  # swallowed by StreamStat.record! itself so a stats-table
481
485
  # outage cannot block the dispatcher.
482
- def record_stat(stream_name:, event_type:, started_at:, fanout: nil)
483
- return unless @config.streams_stats_enabled
486
+ def record_stat(stream_name:, event_type:, started_at:, fanout: nil, ephemeral: false)
487
+ return unless ephemeral || @config.streams_stats_enabled
484
488
 
485
489
  Pgbus::StreamStat.record!(
486
490
  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.2
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