pgbus 0.8.4 → 0.9.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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +16 -0
  3. data/app/assets/javascripts/pgbus/stream_source_element.js +150 -5
  4. data/app/controllers/pgbus/batches_controller.rb +15 -0
  5. data/app/helpers/pgbus/application_helper.rb +12 -0
  6. data/app/views/layouts/pgbus/application.html.erb +2 -0
  7. data/app/views/pgbus/batches/_batches_table.html.erb +54 -0
  8. data/app/views/pgbus/batches/index.html.erb +8 -0
  9. data/app/views/pgbus/batches/show.html.erb +90 -0
  10. data/config/locales/da.yml +34 -0
  11. data/config/locales/de.yml +34 -0
  12. data/config/locales/en.yml +34 -0
  13. data/config/locales/es.yml +34 -0
  14. data/config/locales/fi.yml +34 -0
  15. data/config/locales/fr.yml +34 -0
  16. data/config/locales/it.yml +34 -0
  17. data/config/locales/ja.yml +34 -0
  18. data/config/locales/nb.yml +34 -0
  19. data/config/locales/nl.yml +34 -0
  20. data/config/locales/pt.yml +34 -0
  21. data/config/locales/sv.yml +34 -0
  22. data/config/routes.rb +1 -0
  23. data/lib/pgbus/client.rb +102 -4
  24. data/lib/pgbus/configuration.rb +84 -0
  25. data/lib/pgbus/execution_pools/async_pool.rb +44 -6
  26. data/lib/pgbus/process/supervisor.rb +2 -1
  27. data/lib/pgbus/process/worker.rb +38 -1
  28. data/lib/pgbus/serializer.rb +1 -1
  29. data/lib/pgbus/streams/coalescer.rb +88 -0
  30. data/lib/pgbus/streams/envelope.rb +20 -1
  31. data/lib/pgbus/streams/key.rb +35 -2
  32. data/lib/pgbus/streams/renderer.rb +67 -0
  33. data/lib/pgbus/streams.rb +150 -1
  34. data/lib/pgbus/version.rb +1 -1
  35. data/lib/pgbus/web/data_source.rb +96 -0
  36. data/lib/pgbus/web/stream_app.rb +8 -4
  37. data/lib/pgbus/web/streamer/connection.rb +15 -1
  38. data/lib/pgbus/web/streamer/falcon_connection.rb +9 -1
  39. data/lib/pgbus/web/streamer/heartbeat.rb +23 -1
  40. data/lib/pgbus/web/streamer/stream_event_dispatcher.rb +129 -14
  41. data/lib/pgbus.rb +11 -0
  42. data/lib/tasks/pgbus_queues.rake +54 -0
  43. metadata +10 -3
data/lib/pgbus/streams.rb CHANGED
@@ -15,6 +15,12 @@ module Pgbus
15
15
  # this specifically can rescue Pgbus::Streams::StreamNameTooLong.
16
16
  class StreamNameTooLong < ArgumentError; end
17
17
 
18
+ # The default SSE `event:` name for a broadcast frame. Turbo's
19
+ # StreamObserver consumes frames the client re-dispatches as the
20
+ # `message` DOM event; the client maps this SSE event name to
21
+ # `message`. Broadcasts may override it with a typed name (issue #170).
22
+ DEFAULT_SSE_EVENT = "turbo-stream"
23
+
18
24
  # Process-wide registry of server-side audience filter predicates.
19
25
  # Register filters at boot time via:
20
26
  # Pgbus::Streams.filters.register(:admin_only) { |user| user.admin? }
@@ -28,6 +34,25 @@ module Pgbus
28
34
  @filters = nil
29
35
  end
30
36
 
37
+ # Process-wide publish-side coalescer for high-frequency broadcasts
38
+ # (issue #171). Lazily built; the flush re-enters the normal broadcast
39
+ # path so a coalesced frame is just a deferred ordinary broadcast.
40
+ def self.coalescer
41
+ # `target:` is part of the flush signature but unused here — it's only
42
+ # a coalescing key; the target is already encoded in the payload's
43
+ # turbo-stream tag. Absorbed via ** so it isn't a broadcast argument.
44
+ @coalescer ||= Coalescer.new(
45
+ flush: lambda do |stream_name:, payload:, opts:, **|
46
+ Pgbus.stream(stream_name).broadcast(payload, **opts)
47
+ end
48
+ )
49
+ end
50
+
51
+ # Clears the coalescer. Used by tests; not intended for runtime.
52
+ def self.reset_coalescer!
53
+ @coalescer = nil
54
+ end
55
+
31
56
  # A handle on a single logical stream. The name can be any string, an
32
57
  # object responding to `to_gid_param`, or an array of streamables (which
33
58
  # are joined with colons — turbo-rails-compatible).
@@ -76,9 +101,48 @@ module Pgbus
76
101
  # Per-broadcast `durable:` overrides the stream-level default for a
77
102
  # single broadcast. `nil` (the default) defers to the stream's own
78
103
  # `durable?` setting; `true`/`false` flip the mode for this call only.
79
- def broadcast(payload, visible_to: nil, durable: nil)
104
+ #
105
+ # Actor-echo suppression: pass `exclude:` with the broadcaster's own
106
+ # SSE connection id (surfaced to the page as
107
+ # `<meta name="pgbus-connection-id">` / the element's `connection-id`
108
+ # attribute, sent back on the action request as the
109
+ # `X-Pgbus-Connection` header). The dispatcher skips delivery to that
110
+ # one connection, so the actor doesn't receive the echo of its own
111
+ # broadcast — it already applied the change via the action's HTTP
112
+ # response. Everyone else gets the broadcast. A nil/blank `exclude`
113
+ # is a no-op (the common path).
114
+ # Typed SSE event name: pass `event:` to set the SSE `event:` field
115
+ # on the delivered frame (e.g. `event: "presence"`, `event:
116
+ # "reactive"`) while keeping the payload a Turbo Stream. Clients that
117
+ # care can route on the typed event without sniffing the HTML;
118
+ # default consumers still receive the standard turbo-stream/`message`
119
+ # path. The default (`nil` or `"turbo-stream"`) is not carried on the
120
+ # wire — it's the implicit default the dispatcher applies.
121
+ # High-frequency coalescing: pass `coalesce:` (a window in milliseconds,
122
+ # or `true` for the default window) together with `target:` to batch
123
+ # rapid broadcasts to the same (stream, target) and publish only the
124
+ # latest within the window. Superseded frames never reach the bus.
125
+ # Last-write-wins, so this is only safe for idempotent replace/update
126
+ # of a stable target (the high-frequency case: cursors, typing,
127
+ # progress). Returns nil — the actual broadcast is deferred to the
128
+ # coalescer's flush. See issue #171 and Pgbus::Streams::Coalescer.
129
+ def broadcast(payload, visible_to: nil, durable: nil, exclude: nil, event: nil, coalesce: nil, target: nil)
130
+ if coalesce
131
+ return coalesce_broadcast(
132
+ payload,
133
+ coalesce: coalesce,
134
+ target: target,
135
+ visible_to: visible_to,
136
+ durable: durable,
137
+ exclude: exclude,
138
+ event: event
139
+ )
140
+ end
141
+
80
142
  wrapped = { "html" => payload.to_s }
81
143
  wrapped["visible_to"] = visible_to.to_s if visible_to
144
+ wrapped["exclude"] = exclude.to_s if exclude && !exclude.to_s.empty?
145
+ wrapped["event"] = event.to_s if event && event.to_s != DEFAULT_SSE_EVENT
82
146
 
83
147
  use_durable = durable.nil? ? @durable : durable
84
148
  return broadcast_ephemeral(wrapped) unless use_durable
@@ -101,6 +165,36 @@ module Pgbus
101
165
  end
102
166
  end
103
167
 
168
+ # Renders a renderable (a Phlex component, a ViewComponent, or a
169
+ # pre-rendered HTML string) into a complete `<turbo-stream>` action
170
+ # tag and broadcasts it atomically — the render and the broadcast in
171
+ # one call. This removes the #1 footgun in server-driven UI: building
172
+ # the off-request render context and the turbo-stream wrapper by hand
173
+ # at every call site.
174
+ #
175
+ # Pgbus.stream("chat", room).broadcast_render(
176
+ # renderable: Chat::Message.new(chat_message: msg),
177
+ # action: :append, target: "chat-messages-#{room}",
178
+ # exclude: connection_id # composes with #165
179
+ # )
180
+ #
181
+ # `action` defaults to :replace. `target` is required. `exclude:`,
182
+ # `visible_to:`, and `durable:` are forwarded to #broadcast unchanged,
183
+ # so actor-echo suppression and audience filtering compose. Returns
184
+ # whatever #broadcast returns (msg_id, or nil when deferred to
185
+ # after_commit inside a transaction).
186
+ #
187
+ # See Pgbus::Streams::Renderer for the renderable-resolution order.
188
+ # Components that need URL helpers or a full view context should be
189
+ # rendered by the app (which has the request context) and the
190
+ # resulting string passed as `renderable:`.
191
+ def broadcast_render(target:, action: :replace, renderable: nil, visible_to: nil, durable: nil, exclude: nil,
192
+ event: nil, coalesce: nil)
193
+ html = Renderer.turbo_stream_tag(action: action, target: target, renderable: renderable)
194
+ broadcast(html, visible_to: visible_to, durable: durable, exclude: exclude, event: event,
195
+ coalesce: coalesce, target: target)
196
+ end
197
+
104
198
  def current_msg_id
105
199
  @client.stream_current_msg_id(@name)
106
200
  end
@@ -172,6 +266,61 @@ module Pgbus
172
266
  nil
173
267
  end
174
268
 
269
+ # Submits a frame to the process-wide coalescer instead of
270
+ # broadcasting now. Requires a target (the dedupe key — there's no
271
+ # way to last-write-win without one). The window is `coalesce` in ms
272
+ # when numeric, else the coalescer's default. Returns nil; the
273
+ # coalescer flushes the latest frame as an ordinary broadcast.
274
+ def coalesce_broadcast(payload, coalesce:, target:, visible_to:, durable:, exclude:, event:)
275
+ if target.nil? || target.to_s.empty?
276
+ raise ArgumentError,
277
+ "broadcast(coalesce:) requires target: — coalescing keys on (stream, target), " \
278
+ "so the latest frame per target can win. Use broadcast_render(coalesce:) which " \
279
+ "already has a target, or pass target: explicitly."
280
+ end
281
+
282
+ window_ms = coalesce_window_ms(coalesce)
283
+ # Resolve durability against THIS stream instance now, not nil. The
284
+ # coalescer's flush re-enters Pgbus.stream(@name), whose durability
285
+ # could resolve differently (config patterns) than this instance —
286
+ # passing the resolved value keeps the coalesced frame's mode stable.
287
+ resolved_durable = durable.nil? ? @durable : durable
288
+
289
+ submit = lambda do
290
+ Streams.coalescer.submit(
291
+ stream_name: @name,
292
+ target: target.to_s,
293
+ payload: payload.to_s,
294
+ window_ms: window_ms,
295
+ opts: { visible_to: visible_to, durable: resolved_durable, exclude: exclude, event: event }
296
+ )
297
+ end
298
+
299
+ # Transaction gating: a coalesced DURABLE broadcast made inside an
300
+ # open transaction must not flush if the transaction rolls back, so
301
+ # defer the submission to after_commit (mirrors the non-coalesced
302
+ # path). Ephemeral coalescing is fire-and-forget, so it submits now.
303
+ transaction = resolved_durable ? current_open_transaction : nil
304
+ if transaction
305
+ transaction.after_commit { submit.call }
306
+ else
307
+ submit.call
308
+ end
309
+ nil
310
+ end
311
+
312
+ # Resolves the coalescing window in ms. `true` → the default window;
313
+ # a positive Numeric is used as-is. Anything else is a misuse and is
314
+ # rejected at the API boundary with an actionable error rather than a
315
+ # cryptic NoMethodError deep inside the coalescer's timer math.
316
+ def coalesce_window_ms(coalesce)
317
+ return Coalescer::DEFAULT_WINDOW_MS if coalesce == true
318
+ return coalesce if coalesce.is_a?(Numeric) && coalesce.positive?
319
+
320
+ raise ArgumentError,
321
+ "coalesce: must be true or a positive Numeric window in milliseconds, got #{coalesce.inspect}"
322
+ end
323
+
175
324
  def ensure_queue!
176
325
  return if @ensured
177
326
 
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.4"
4
+ VERSION = "0.9.2"
5
5
  end
@@ -590,6 +590,43 @@ module Pgbus
590
590
  []
591
591
  end
592
592
 
593
+ # Batches
594
+ def batches(limit: 100)
595
+ BatchEntry.order(created_at: :desc).limit(limit).map { |r| format_batch(r) }
596
+ rescue StandardError => e
597
+ Pgbus.logger.debug { "[Pgbus::Web] Error fetching batches: #{e.message}" }
598
+ []
599
+ end
600
+
601
+ def batch_detail(batch_id)
602
+ record = BatchEntry.find_by(batch_id: batch_id)
603
+ return nil unless record
604
+
605
+ format_batch(record).merge(
606
+ properties: record.properties,
607
+ on_finish_class: record.on_finish_class,
608
+ on_success_class: record.on_success_class,
609
+ on_discard_class: record.on_discard_class
610
+ )
611
+ rescue StandardError => e
612
+ Pgbus.logger.debug { "[Pgbus::Web] Error fetching batch #{batch_id}: #{e.message}" }
613
+ nil
614
+ end
615
+
616
+ def batches_count
617
+ BatchEntry.count
618
+ rescue StandardError => e
619
+ Pgbus.logger.debug { "[Pgbus::Web] Error counting batches: #{e.message}" }
620
+ 0
621
+ end
622
+
623
+ def active_batches_count
624
+ BatchEntry.where.not(status: "finished").count
625
+ rescue StandardError => e
626
+ Pgbus.logger.debug { "[Pgbus::Web] Error counting active batches: #{e.message}" }
627
+ 0
628
+ end
629
+
593
630
  # Job stats
594
631
  def job_stats_summary(minutes: 60)
595
632
  JobStat.summary(minutes: minutes)
@@ -874,6 +911,33 @@ module Pgbus
874
911
  []
875
912
  end
876
913
 
914
+ # NOTIFY throttle status for all queues with notifications enabled.
915
+ # Returns an array of hashes: { queue_name:, throttle_interval_ms:, last_notified_at: }
916
+ def notify_throttles
917
+ @client.list_notify_insert_throttles.map do |throttle|
918
+ {
919
+ queue_name: throttle.queue_name,
920
+ throttle_interval_ms: throttle.throttle_interval_ms,
921
+ last_notified_at: throttle.last_notified_at
922
+ }
923
+ end
924
+ rescue StandardError => e
925
+ Pgbus.logger.debug { "[Pgbus::Web] Error fetching notify throttles: #{e.message}" }
926
+ []
927
+ end
928
+
929
+ # FIFO group head sampling for a specific queue.
930
+ # Returns the oldest visible message from each distinct group (up to qty).
931
+ # Useful for detecting head-of-line stalls in multi-tenant queues.
932
+ def queue_group_heads(queue_name, qty: 20)
933
+ logical = logical_queue_name(queue_name)
934
+ messages = @client.read_grouped_head(logical, qty: qty) || []
935
+ messages.map { |m| format_pgmq_message(m, queue_name) }
936
+ rescue StandardError => e
937
+ Pgbus.logger.debug { "[Pgbus::Web] Error fetching group heads for #{queue_name}: #{e.message}" }
938
+ []
939
+ end
940
+
877
941
  private
878
942
 
879
943
  def connection
@@ -1114,6 +1178,19 @@ module Pgbus
1114
1178
  }
1115
1179
  end
1116
1180
 
1181
+ def format_pgmq_message(msg, queue_name)
1182
+ {
1183
+ msg_id: msg.msg_id.to_i,
1184
+ read_ct: msg.read_ct.to_i,
1185
+ enqueued_at: msg.enqueued_at,
1186
+ last_read_at: msg.respond_to?(:last_read_at) ? msg.last_read_at : nil,
1187
+ vt: msg.respond_to?(:vt) ? msg.vt : nil,
1188
+ message: msg.message,
1189
+ headers: msg.headers,
1190
+ queue_name: queue_name
1191
+ }
1192
+ end
1193
+
1117
1194
  def format_process(row)
1118
1195
  heartbeat = row["last_heartbeat_at"]
1119
1196
  heartbeat_time = heartbeat.is_a?(String) ? Time.parse(heartbeat) : heartbeat
@@ -1131,6 +1208,25 @@ module Pgbus
1131
1208
  }
1132
1209
  end
1133
1210
 
1211
+ def format_batch(record)
1212
+ total = record.total_jobs
1213
+ done = record.completed_jobs + record.discarded_jobs
1214
+ pct = total.positive? ? ((done * 100) / total) : 100
1215
+
1216
+ {
1217
+ batch_id: record.batch_id,
1218
+ description: record.description,
1219
+ status: record.status,
1220
+ total_jobs: total,
1221
+ completed_jobs: record.completed_jobs,
1222
+ discarded_jobs: record.discarded_jobs,
1223
+ failed_jobs: record.failed_jobs,
1224
+ progress_pct: pct,
1225
+ created_at: record.created_at,
1226
+ finished_at: record.finished_at
1227
+ }
1228
+ end
1229
+
1134
1230
  def sanitize_name(name)
1135
1231
  QueueNameValidator.sanitize!(name)
1136
1232
  end
@@ -117,10 +117,11 @@ module Pgbus
117
117
  hijack_result = env["rack.hijack"].call
118
118
  io = hijack_result || env["rack.hijack_io"]
119
119
 
120
- write_headers(io, stream_name: stream_name, since_id: since_id)
120
+ connection_id = SecureRandom.hex(8)
121
+ write_headers(io, stream_name: stream_name, since_id: since_id, connection_id: connection_id)
121
122
 
122
123
  connection = Pgbus::Web::Streamer::Connection.new(
123
- id: SecureRandom.hex(8),
124
+ id: connection_id,
124
125
  stream_name: stream_name,
125
126
  io: io,
126
127
  since_id: since_id,
@@ -138,14 +139,16 @@ module Pgbus
138
139
  require_relative "streamer/falcon_connection" unless defined?(Pgbus::Web::Streamer::FalconConnection)
139
140
 
140
141
  body = ::Protocol::HTTP::Body::Writable.new
142
+ connection_id = SecureRandom.hex(8)
141
143
 
142
144
  body.write(Pgbus::Streams::Envelope.retry_directive(2_000))
143
145
  body.write(Pgbus::Streams::Envelope.comment(
144
146
  "pgbus stream open since_id=#{since_id} stream=#{stream_name}"
145
147
  ))
148
+ body.write(Pgbus::Streams::Envelope.connected(id: connection_id))
146
149
 
147
150
  connection = Pgbus::Web::Streamer::FalconConnection.new(
148
- id: SecureRandom.hex(8),
151
+ id: connection_id,
149
152
  stream_name: stream_name,
150
153
  body: body,
151
154
  since_id: since_id,
@@ -165,10 +168,11 @@ module Pgbus
165
168
  }
166
169
  end
167
170
 
168
- def write_headers(io, stream_name:, since_id:)
171
+ def write_headers(io, stream_name:, since_id:, connection_id:)
169
172
  io.write(Pgbus::Streams::Envelope.http_response_headers)
170
173
  io.write(Pgbus::Streams::Envelope.retry_directive(2_000))
171
174
  io.write(Pgbus::Streams::Envelope.comment("pgbus stream open since_id=#{since_id} stream=#{stream_name}"))
175
+ io.write(Pgbus::Streams::Envelope.connected(id: connection_id))
172
176
  end
173
177
 
174
178
  def streamer
@@ -14,6 +14,10 @@ module Pgbus
14
14
  # the client-side leg of the replay-race fix (§6.5 of the design doc).
15
15
  class Connection
16
16
  attr_reader :id, :stream_name, :io, :mutex, :last_msg_id_sent, :context
17
+ # The presence member id this connection auto-joined as, or nil for
18
+ # non-presence streams / anonymous connections. Set by the
19
+ # Dispatcher on connect; read on disconnect and heartbeat touch.
20
+ attr_accessor :presence_member
17
21
 
18
22
  def initialize(id:, stream_name:, io:, since_id:, writer:, write_deadline_ms:, context: nil)
19
23
  @id = id
@@ -25,6 +29,7 @@ module Pgbus
25
29
  @mutex = Mutex.new
26
30
  @dead = false
27
31
  @closed = false
32
+ @presence_member = nil
28
33
  @created_at = monotonic
29
34
  @last_write_at = @created_at
30
35
  # Context is whatever the StreamApp's authorize hook returned
@@ -43,7 +48,7 @@ module Pgbus
43
48
 
44
49
  bytes = Pgbus::Streams::Envelope.message(
45
50
  id: envelope.msg_id,
46
- event: "turbo-stream",
51
+ event: sse_event_for(envelope),
47
52
  data: envelope.payload
48
53
  )
49
54
 
@@ -115,6 +120,15 @@ module Pgbus
115
120
 
116
121
  private
117
122
 
123
+ # The SSE event name for a frame: the envelope's typed event when
124
+ # present, else the default turbo-stream. Plain ReadAfter::Envelopes
125
+ # (no event field) and StreamEnvelopes with a nil event both fall
126
+ # back to the default. (issue #170)
127
+ def sse_event_for(envelope)
128
+ event = envelope.respond_to?(:event) ? envelope.event : nil
129
+ event && !event.to_s.empty? ? event.to_s : Pgbus::Streams::DEFAULT_SSE_EVENT
130
+ end
131
+
118
132
  def monotonic
119
133
  # Qualify ::Process because Pgbus::Process already exists as a
120
134
  # namespace for worker/supervisor/consumer and would otherwise
@@ -13,6 +13,7 @@ module Pgbus
13
13
  # is fiber-safe under Falcon's scheduler.
14
14
  class FalconConnection
15
15
  attr_reader :id, :stream_name, :io, :mutex, :last_msg_id_sent, :context
16
+ attr_accessor :presence_member
16
17
 
17
18
  def initialize(id:, stream_name:, body:, since_id:, write_deadline_ms:, context: nil)
18
19
  @id = id
@@ -24,6 +25,7 @@ module Pgbus
24
25
  @mutex = Mutex.new
25
26
  @dead = false
26
27
  @closed = false
28
+ @presence_member = nil
27
29
  @created_at = monotonic
28
30
  @last_write_at = @created_at
29
31
  @context = context
@@ -36,7 +38,7 @@ module Pgbus
36
38
 
37
39
  bytes = Pgbus::Streams::Envelope.message(
38
40
  id: envelope.msg_id,
39
- event: "turbo-stream",
41
+ event: sse_event_for(envelope),
40
42
  data: envelope.payload
41
43
  )
42
44
 
@@ -94,6 +96,12 @@ module Pgbus
94
96
 
95
97
  private
96
98
 
99
+ # See Connection#sse_event_for. (issue #170)
100
+ def sse_event_for(envelope)
101
+ event = envelope.respond_to?(:event) ? envelope.event : nil
102
+ event && !event.to_s.empty? ? event.to_s : Pgbus::Streams::DEFAULT_SSE_EVENT
103
+ end
104
+
97
105
  def write_to_body(bytes)
98
106
  @mutex.synchronize do
99
107
  return :closed if @dead || @body.closed?
@@ -61,6 +61,7 @@ module Pgbus
61
61
  # code goes through the background thread.
62
62
  def tick
63
63
  now = @clock.call
64
+ presence_conns = []
64
65
  @registry.each_connection do |connection|
65
66
  if connection.dead?
66
67
  # Already dead (e.g. IoWriter returned :closed on a previous
@@ -76,8 +77,15 @@ module Pgbus
76
77
  end
77
78
 
78
79
  result = connection.write_comment("heartbeat #{now.to_i}")
79
- enqueue_disconnect(connection) if connection.dead? || result != :ok
80
+ if connection.dead? || result != :ok
81
+ enqueue_disconnect(connection)
82
+ next
83
+ end
84
+
85
+ presence_conns << connection if presence_member?(connection)
80
86
  end
87
+
88
+ enqueue_presence_touch(presence_conns)
81
89
  end
82
90
 
83
91
  private
@@ -99,6 +107,20 @@ module Pgbus
99
107
  def enqueue_disconnect(connection)
100
108
  @queue << StreamEventDispatcher::DisconnectMessage.new(connection: connection)
101
109
  end
110
+
111
+ def presence_member?(connection)
112
+ connection.respond_to?(:presence_member) && !connection.presence_member.nil?
113
+ end
114
+
115
+ # Posts one batched touch per tick so the dispatcher (which owns AR
116
+ # connection release) refreshes last_seen_at for live presence
117
+ # members, keeping them out of the sweeper. No message when no
118
+ # connection has presence — avoids waking the dispatcher for nothing.
119
+ def enqueue_presence_touch(connections)
120
+ return if connections.empty?
121
+
122
+ @queue << StreamEventDispatcher::PresenceTouchMessage.new(connections: connections)
123
+ end
102
124
  end
103
125
  end
104
126
  end