pgbus 0.9.0 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 93b9de67b282c7c707b2dcd4222ddde62d758447cc4769f72046dcf5b017dd92
4
- data.tar.gz: 1a30a5e46ec20f02fa1cca3d56435c4e2c7f426a3933c523351503f0b69bff64
3
+ metadata.gz: 5027c420c26dac65291e11e07ca0291da540b7d2aef9d963e9fdebc2217e7943
4
+ data.tar.gz: 9e66b85229c55528b3b5b2093883935c1333733018508bb6d9f7f7836666363d
5
5
  SHA512:
6
- metadata.gz: 8912988721d6dfcf064693b9a84a6d1925a3535535b4b584abbdb2d311f80a5c1d7f6503c72da2825cbdfaf8daedb9c9e04b53353d3cc81279945b616a47a204
7
- data.tar.gz: '0668b1229a24e47bcdc7fbb3d5f5fcfe95417329be2cdf14eb039cd9e258c22cefcaa4dbb3f2d17a84d5f0aa2c8a6def94971f9a51913ae028f395ac7758168c'
6
+ metadata.gz: 9c476c7de9c0db40e0d2183a11f7d502704a468e68f49b28934619cfab07f1130f3988d6f0fbfa6e77a57d6450aa183746b301367e0d4af7dcd2779319f96ff7
7
+ data.tar.gz: ed62152e6c2c361c5b8f38c61e31ea4e3af5d031cf68f71d0ea95177ab04f538bfbb4fefc84f5d08a2acd8efb23b0024d6433d4a6db2272e7c05521a59e83a3b
data/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ### Added
4
+
5
+ - **Streams: optional publish-side coalescing for high-frequency broadcasts.** A chatty reactive component (live cursor, typing indicator, progress bar) can fan out many small broadcasts per second. Pass `coalesce:` (a window in milliseconds, or `true` for the 50ms default) with `target:` to `broadcast`/`broadcast_render` to batch per `(stream, target)` and publish only the *latest* frame within the window — superseded frames never hit the bus (no PGMQ insert, no NOTIFY, no fan-out). Last-write-wins, so it is opt-in and only safe for idempotent `replace`/`update` of a stable target (exactly the high-frequency case). The new `Pgbus::Streams::Coalescer` is a process-wide, thread-safe, trailing-edge-with-max-wait debounce: the first submit per window schedules the flush (bounding latency to one window) and later submits only overwrite the buffered payload; the flush re-enters the normal broadcast path, so a coalesced frame is just a deferred ordinary broadcast that still composes with `visible_to`/`exclude`/`event`/`durable`. Refs #171.
6
+
7
+ - **Streams: typed SSE event names on broadcasts.** A broadcast can set the SSE `event:` field — `Pgbus.stream(name).broadcast(html, event: "presence")` (and `broadcast_render(..., event:)`) — while keeping the payload a Turbo Stream, so clients route on the typed name instead of sniffing the HTML. The default (`nil` or `"turbo-stream"`) is omitted from the JSONB payload to avoid redundancy, but is still set on the SSE frame's `event:` line (the connection adapter falls back to `turbo-stream`), so default consumers still get the standard `message`/turbo-stream path. The event flows through the JSONB payload → `StreamEnvelope.event` → the SSE frame's `event:` line (both the Puma and Falcon connection adapters). On the client, `<pgbus-stream-source>` dispatches a typed broadcast as a generic `pgbus:event` (`{ event, data, msgId }`) and a named `pgbus:<event>` (`{ data, msgId }`) for `addEventListener` ergonomics; declare typed event names via the element's `listen-events` attribute so they survive the EventSource reconnect path. Refs #170.
8
+
9
+ - **Streams: optional connection-driven presence.** Streams matching `config.streams_presence_patterns` (exact string or Regexp, mirroring `streams_durable_patterns`) now auto-join a member on SSE connect, auto-leave on disconnect, and refresh `last_seen_at` on the keepalive heartbeat — no explicit `join`/`leave`/sweeper wiring. Identity comes from the connection's authorize-hook context: the built-in extractor handles a Hash with `:member_id`/`:id` (plus optional `:metadata`) or any object responding to `#id`, and `config.streams_presence_member` accepts a custom `->(context) { { id:, metadata: } }`. Membership work runs on the dispatcher thread (which already releases AR connections each pass); the heartbeat posts a batched `PresenceTouchMessage` per tick. Presence failures are logged and swallowed so a presence-DB hiccup can't knock a live SSE connection out of the registry. Anonymous connections (no derivable member) are simply skipped. Refs #169.
10
+
11
+ - **Streams: msg_id (revision) exposed to the client for optimistic-UI reconciliation.** Each delivered turbo-stream frame already carries its monotonic `msg_id` as the SSE `id:`; `<pgbus-stream-source>` now surfaces it two ways: the standard `message` MessageEvent sets `lastEventId` to the msg_id (Turbo ignores it; a reactive runtime listening for `message` reads the revision with no pgbus-specific API), and a new `pgbus:message` CustomEvent carries `{ msgId, data }` (msgId is a Number when numeric; a negative value marks an ephemeral frame). Documents the reconciliation pattern: track the highest applied msgId per target and skip the morph if a newer revision was already applied, so a late echo can't clobber a newer optimistic edit. Complements #165 (exclude handles the actor; the revision handles out-of-order delivery for everyone else). Refs #168.
12
+
13
+ - **Streams: `Stream#broadcast_render` — render a renderable and broadcast it in one call.** `Pgbus.stream(name).broadcast_render(renderable:, target:, action: :replace, exclude:, visible_to:, durable:)` renders a Phlex component, a ViewComponent, or a pre-rendered HTML string into a complete `<turbo-stream action target><template>…</template></turbo-stream>` tag and broadcasts it atomically — removing the off-request render + tag-building boilerplate (and the easy-to-get-wrong view context) from every call site. `action` defaults to `:replace`; content-less actions (`remove`) emit no `<template>`. `exclude:`/`visible_to:`/`durable:` forward to `#broadcast`, so actor-echo suppression and audience filtering compose. Self-contained (no turbo-rails/ActionView/Phlex dependency); the renderable is resolved via `String` → `#call` (Phlex) → `#render_in` (ViewComponent) → `#to_s`. Refs #166.
14
+
15
+ - **Streams: actor-echo suppression via `exclude:` on broadcasts.** A broadcast can now name a connection id to skip: `Pgbus.stream(name).broadcast(html, exclude: connection_id)` (and `visible_to:` composes with it). The dispatcher skips delivery to that one SSE connection, so an actor who triggered a change does not receive the echo of its own broadcast — it already applied the change via its action's HTTP response, and re-applying the SSE echo would double-apply (re-run animations, clobber optimistic edits). Every server-minted SSE connection now exposes its id to the page: the server sends a `pgbus:connected` frame right after the open handshake, and `<pgbus-stream-source>` captures it onto the element's `connection-id` attribute and re-dispatches it as a `pgbus:connected` event (also surfaced in `pgbus:open`'s detail). A page reads its connection id and sends it back as the `X-Pgbus-Connection` header on action requests; the broadcaster passes `exclude: request.headers["X-Pgbus-Connection"]`. Reuses the existing per-connection delivery (Filters) path. Refs #165.
16
+
17
+ - **Streams: `Pgbus.stream_key` is idempotent for an already-built key, plus `Pgbus.stream_key!`.** A single `String` argument is now treated as a pre-built pgbus stream key and returned unchanged (after the queue-name budget check) instead of tripping the colon-separator guard. This lets a consumer hold one `stream_key` value and pass it to both `turbo_stream_from` and the broadcaster without `stream_key("chat:lobby")` raising `ArgumentError`. The guard still fires for the genuinely ambiguous multi-fragment join (`stream_key("a:b", :c)`), and `Symbol`/record fragments with colons are still rejected (a colon there never came from `stream_key`). `Pgbus.stream_key!(key)` accepts a pre-built key explicitly (String required, budget enforced). Refs #167.
18
+
3
19
  ### Breaking Changes
4
20
 
5
21
  - **Queue names must be alphanumeric and underscores only.** Queue names containing dashes (e.g., `my-app-queue`) will now raise `ArgumentError`. Rename to underscored form (e.g., `my_app_queue`) before upgrading. This restriction prevents SQL injection via PGMQ queue identifiers, which are interpolated into table names and cannot be parameterized.
@@ -17,12 +17,32 @@
17
17
  // channel — compatibility shim; ignored
18
18
  //
19
19
  // Events (dispatched on the element):
20
- // pgbus:open { lastEventId }
20
+ // message (MessageEvent) data = turbo-stream HTML;
21
+ // lastEventId = the frame's msg_id (revision)
22
+ // pgbus:message { msgId, data } — same frame, for optimistic-UI
23
+ // reconciliation (skip morph if a newer rev for the
24
+ // target was already applied). See #168.
25
+ // pgbus:event { event, data, msgId } — a typed broadcast (any SSE
26
+ // event name other than turbo-stream). See #170.
27
+ // pgbus:<event> { data, msgId } — the same typed broadcast, named
28
+ // for ergonomic addEventListener("pgbus:presence").
29
+ // pgbus:open { lastEventId, connectionId }
30
+ // pgbus:connected { connectionId }
21
31
  // pgbus:replay-start { fromId, toId }
22
32
  // pgbus:replay-end {}
23
33
  // pgbus:gap-detected { lastSeenId, archiveOldestId }
24
34
  // pgbus:close { code, reason }
25
35
  //
36
+ // Connection id (issue #165 — actor-echo suppression): the server sends a
37
+ // `pgbus:connected` frame right after the open handshake carrying the
38
+ // server-minted connection id. This element captures it, reflects it onto
39
+ // the `connection-id` attribute, and re-dispatches it as `pgbus:connected`.
40
+ // A page reads it (from the element or a `<meta name="pgbus-connection-id">`
41
+ // the app mirrors it to) and sends it back as the `X-Pgbus-Connection`
42
+ // header on action requests. The broadcaster then passes
43
+ // `exclude: request.headers["X-Pgbus-Connection"]` so the actor does not
44
+ // receive the echo of its own broadcast.
45
+ //
26
46
  // The element integrates with Turbo via connectStreamSource /
27
47
  // disconnectStreamSource + dispatching MessageEvent("message") so Turbo
28
48
  // Stream HTML is automatically consumed by the existing StreamObserver.
@@ -48,9 +68,17 @@ class PgbusStreamSourceElement extends HTMLElement {
48
68
  this.abortController = null
49
69
  this.eventSource = null
50
70
  this.lastEventId = null
71
+ this.connectionId = null
51
72
  this.closed = false
52
73
  }
53
74
 
75
+ // The server-minted connection id for this SSE connection, or null
76
+ // until the `pgbus:connected` frame arrives. Public read accessor so
77
+ // a reactive runtime can grab it without poking at attributes.
78
+ get pgbusConnectionId() {
79
+ return this.connectionId
80
+ }
81
+
54
82
  connectedCallback() {
55
83
  this.closed = false
56
84
  connectStreamSource(this)
@@ -80,6 +108,12 @@ class PgbusStreamSourceElement extends HTMLElement {
80
108
  // the URL. Parses the SSE event stream by hand because EventSource
81
109
  // doesn't expose custom query strings uniformly across browsers.
82
110
  async openFetchStream() {
111
+ // Each transport open is a fresh connection: the server mints a new id
112
+ // and sends a new pgbus:connected frame. Clear the previous id so
113
+ // pgbus:open (which fires before that frame arrives) can't surface a
114
+ // stale connection id — a stale id would produce a wrong X-Pgbus-Connection
115
+ // exclude header during reconnect windows.
116
+ this.resetConnectionId()
83
117
  const url = this.buildUrl({ includeSince: true })
84
118
  this.abortController = new AbortController()
85
119
 
@@ -99,7 +133,7 @@ class PgbusStreamSourceElement extends HTMLElement {
99
133
 
100
134
  this.setAttribute("connected", "")
101
135
  this.dispatchEvent(new CustomEvent("pgbus:open", {
102
- detail: { lastEventId: this.lastEventId }
136
+ detail: { lastEventId: this.lastEventId, connectionId: this.connectionId }
103
137
  }))
104
138
 
105
139
  const reader = response.body.getReader()
@@ -137,13 +171,17 @@ class PgbusStreamSourceElement extends HTMLElement {
137
171
  switchToEventSource() {
138
172
  if (this.closed) return
139
173
 
174
+ // Fresh connection on reconnect — drop the previous connection id so
175
+ // pgbus:open can't emit a stale one before the new pgbus:connected
176
+ // frame lands. See openFetchStream.
177
+ this.resetConnectionId()
140
178
  const url = this.buildUrl({ includeSince: true })
141
179
  this.eventSource = new EventSource(url, { withCredentials: true })
142
180
 
143
181
  this.eventSource.addEventListener("open", () => {
144
182
  this.setAttribute("connected", "")
145
183
  this.dispatchEvent(new CustomEvent("pgbus:open", {
146
- detail: { lastEventId: this.lastEventId }
184
+ detail: { lastEventId: this.lastEventId, connectionId: this.connectionId }
147
185
  }))
148
186
  })
149
187
 
@@ -153,7 +191,11 @@ class PgbusStreamSourceElement extends HTMLElement {
153
191
 
154
192
  this.eventSource.addEventListener("turbo-stream", (event) => {
155
193
  this.lastEventId = event.lastEventId
156
- this.dispatchEvent(new MessageEvent("message", { data: event.data }))
194
+ this.emitTurboStream(event.data, event.lastEventId)
195
+ })
196
+
197
+ this.eventSource.addEventListener("pgbus:connected", (event) => {
198
+ this.handleConnected(event.data)
157
199
  })
158
200
 
159
201
  this.eventSource.addEventListener("pgbus:gap-detected", (event) => {
@@ -166,6 +208,15 @@ class PgbusStreamSourceElement extends HTMLElement {
166
208
  detail: { code: "shutdown", reason: "worker restart" }
167
209
  }))
168
210
  })
211
+
212
+ // Typed broadcasts (issue #170): EventSource only fires listeners
213
+ // registered by name, so we register one per declared typed event.
214
+ for (const name of this.declaredTypedEvents()) {
215
+ this.eventSource.addEventListener(name, (event) => {
216
+ if (event.lastEventId) this.lastEventId = event.lastEventId
217
+ this.emitTypedEvent(name, event.data, event.lastEventId)
218
+ })
219
+ }
169
220
  }
170
221
 
171
222
  // Parses a single SSE event block (: comment | id: ... | event: ... | data: ...)
@@ -185,7 +236,9 @@ class PgbusStreamSourceElement extends HTMLElement {
185
236
  if (id !== null) this.lastEventId = id
186
237
 
187
238
  if (event === "turbo-stream") {
188
- this.dispatchEvent(new MessageEvent("message", { data }))
239
+ this.emitTurboStream(data, id)
240
+ } else if (event === "pgbus:connected") {
241
+ this.handleConnected(data)
189
242
  } else if (event === "pgbus:gap-detected") {
190
243
  this.dispatchEvent(new CustomEvent("pgbus:gap-detected", {
191
244
  detail: this.safeJsonParse(data)
@@ -194,9 +247,101 @@ class PgbusStreamSourceElement extends HTMLElement {
194
247
  this.dispatchEvent(new CustomEvent("pgbus:close", {
195
248
  detail: { code: "shutdown", reason: "worker restart" }
196
249
  }))
250
+ } else {
251
+ // A typed broadcast (issue #170): event: presence | reactive | ...
252
+ this.emitTypedEvent(event, data, id)
197
253
  }
198
254
  }
199
255
 
256
+ // Captures the server-minted connection id from a `pgbus:connected`
257
+ // frame: stores it, reflects it onto the `connection-id` attribute (so
258
+ // it's visible in the DOM / to MutationObservers), and re-dispatches it
259
+ // as a `pgbus:connected` CustomEvent. Idempotent across reconnects — the
260
+ // server mints a fresh id per connection, so a reconnect updates it.
261
+ handleConnected(data) {
262
+ const detail = this.safeJsonParse(data)
263
+ const connectionId = detail && detail.connectionId
264
+ if (!connectionId) return
265
+
266
+ this.connectionId = connectionId
267
+ this.setAttribute("connection-id", connectionId)
268
+ this.dispatchEvent(new CustomEvent("pgbus:connected", {
269
+ detail: { connectionId }
270
+ }))
271
+ }
272
+
273
+ // Clears the cached connection id and its reflected attribute. Called at
274
+ // the start of every transport open so a reconnect doesn't carry the
275
+ // previous connection's id into pgbus:open before the new pgbus:connected
276
+ // frame arrives.
277
+ resetConnectionId() {
278
+ this.connectionId = null
279
+ this.removeAttribute("connection-id")
280
+ }
281
+
282
+ // Delivers a turbo-stream frame to two audiences (issue #168):
283
+ //
284
+ // 1. Turbo's StreamObserver, via a `message` MessageEvent whose `data`
285
+ // is the turbo-stream HTML. The MessageEvent's standard
286
+ // `lastEventId` field carries the frame's msg_id, so a reactive
287
+ // runtime that listens for `message` can read the revision without
288
+ // any pgbus-specific API. (Turbo ignores lastEventId.)
289
+ //
290
+ // 2. A reactive runtime doing optimistic-UI reconciliation, via a
291
+ // `pgbus:message` CustomEvent carrying { msgId, data }. msgId is the
292
+ // monotonic per-stream revision (a negative value marks an ephemeral
293
+ // frame that was never persisted). Pattern: track the highest msgId
294
+ // you have applied per target; when a frame arrives, skip the morph
295
+ // if you have already applied a newer revision for that target —
296
+ // this stops a late echo from clobbering a newer optimistic edit.
297
+ // Complements #165: exclude handles the actor; this handles
298
+ // out-of-order delivery for everyone else.
299
+ //
300
+ // msgId is parsed to a Number when numeric so consumers can compare
301
+ // revisions with `>` directly; left as-is otherwise.
302
+ emitTurboStream(data, id) {
303
+ const msgId = id === null || id === undefined || id === "" ? null
304
+ : (Number.isNaN(Number(id)) ? id : Number(id))
305
+
306
+ const message = new MessageEvent("message", { data, lastEventId: id == null ? "" : String(id) })
307
+ this.dispatchEvent(message)
308
+
309
+ this.dispatchEvent(new CustomEvent("pgbus:message", {
310
+ detail: { msgId, data }
311
+ }))
312
+ }
313
+
314
+ // Delivers a typed broadcast (issue #170) — a frame whose SSE event name
315
+ // is something other than turbo-stream (e.g. "presence", "reactive").
316
+ // The payload is still whatever the broadcaster sent (usually a Turbo
317
+ // Stream); the typed name lets a client route without sniffing the HTML.
318
+ // Dispatched two ways for ergonomics:
319
+ // - a generic `pgbus:event` { event, data, msgId } (one listener for all)
320
+ // - a named `pgbus:<event>` { data, msgId } (addEventListener by name)
321
+ emitTypedEvent(event, data, id) {
322
+ const msgId = id === null || id === undefined || id === "" ? null
323
+ : (Number.isNaN(Number(id)) ? id : Number(id))
324
+
325
+ this.dispatchEvent(new CustomEvent("pgbus:event", {
326
+ detail: { event, data, msgId }
327
+ }))
328
+ this.dispatchEvent(new CustomEvent(`pgbus:${event}`, {
329
+ detail: { data, msgId }
330
+ }))
331
+ }
332
+
333
+ // Typed event names the EventSource (reconnect) path should listen for,
334
+ // declared by the app via the `listen-events` attribute (comma- or
335
+ // space-separated). EventSource only invokes listeners registered by
336
+ // name, so unlike the fetch path it cannot route unknown typed events
337
+ // generically; declaring them here keeps typed delivery working across
338
+ // reconnects. The fetch (initial) path always routes typed events.
339
+ declaredTypedEvents() {
340
+ const raw = this.getAttribute("listen-events")
341
+ if (!raw) return []
342
+ return raw.split(/[\s,]+/).filter((name) => name && name !== "turbo-stream")
343
+ }
344
+
200
345
  buildUrl({ includeSince }) {
201
346
  const src = this.getAttribute("src")
202
347
  if (!includeSince || !this.lastEventId) return src
@@ -16,7 +16,8 @@
16
16
  <tr class="hover:bg-gray-50 dark:hover:bg-gray-700/50">
17
17
  <td data-label="Batch ID" class="px-4 py-3 text-sm">
18
18
  <%= link_to batch[:batch_id][0..7], pgbus.batch_path(batch[:batch_id]),
19
- class: "font-mono text-indigo-600 dark:text-indigo-400 hover:underline" %>
19
+ class: "font-mono text-indigo-600 dark:text-indigo-400 hover:underline",
20
+ data: { turbo_frame: "_top" } %>
20
21
  </td>
21
22
  <td data-label="Description" class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300 max-w-xs truncate">
22
23
  <%= batch[:description] || "—" %>
@@ -113,6 +113,7 @@ module Pgbus
113
113
  :streams_stats_enabled, :streams_test_mode,
114
114
  :streams_orphan_sweep_interval, :streams_orphan_threshold,
115
115
  :streams_durable_patterns,
116
+ :streams_presence_patterns, :streams_presence_member,
116
117
  :streams_host, :streams_port, :streams_database_url
117
118
  attr_reader :streams_default_broadcast_mode # rubocop:disable Style/AccessorGrouping
118
119
 
@@ -247,6 +248,15 @@ module Pgbus
247
248
  @streams_orphan_sweep_interval = 3600 # 1 hour
248
249
  @streams_orphan_threshold = 86_400 # 24 hours
249
250
  @streams_durable_patterns = []
251
+ # Streams matching these patterns get connection-driven presence:
252
+ # auto-join on SSE connect, auto-leave on disconnect, touch on the
253
+ # keepalive heartbeat (issue #169). Empty by default (opt-in).
254
+ @streams_presence_patterns = []
255
+ # Extracts a presence member { id:, metadata: } from a connection's
256
+ # authorize-hook context. Defaults to nil, which uses the built-in
257
+ # extractor (see #presence_member_for): a Hash with :member_id/:id,
258
+ # or an object responding to #id.
259
+ @streams_presence_member = nil
250
260
 
251
261
  # AppSignal: auto-on when the appsignal gem is loaded; probe runs in
252
262
  # the same process, so the operator can disable it independently.
@@ -411,6 +421,12 @@ module Pgbus
411
421
 
412
422
  raise ArgumentError, "streams_durable_patterns must be an Array of strings/regex" unless streams_durable_patterns.is_a?(Array)
413
423
 
424
+ raise ArgumentError, "streams_presence_patterns must be an Array of strings/regex" unless streams_presence_patterns.is_a?(Array)
425
+
426
+ if !streams_presence_member.nil? && !streams_presence_member.respond_to?(:call)
427
+ raise ArgumentError, "streams_presence_member must respond to #call (a Proc/lambda) or be nil"
428
+ end
429
+
414
430
  return if streams_orphan_threshold.nil?
415
431
  return if streams_orphan_threshold.is_a?(Numeric) && streams_orphan_threshold.positive?
416
432
 
@@ -427,6 +443,35 @@ module Pgbus
427
443
  streams_default_broadcast_mode == :durable
428
444
  end
429
445
 
446
+ # Returns true if the given stream name should have connection-driven
447
+ # presence based on `streams_presence_patterns` (exact string or
448
+ # Regexp match). Presence is opt-in, so the default (no patterns) is
449
+ # false. See issue #169.
450
+ def stream_presence?(name)
451
+ patterns = streams_presence_patterns || []
452
+ patterns.any? { |p| p.is_a?(Regexp) ? p.match?(name) : p == name }
453
+ end
454
+
455
+ # Derives a presence member { id:, metadata: } from a connection's
456
+ # authorize-hook context, or nil when no member can be derived (an
457
+ # anonymous connection — presence is simply skipped). Uses
458
+ # `streams_presence_member` when configured; otherwise the built-in
459
+ # extractor handles the common shapes:
460
+ # - Hash with :member_id (or :id) and optional :metadata
461
+ # - an object responding to #id (e.g. a User model)
462
+ # The id is always coerced to a String and metadata defaults to {}.
463
+ def presence_member_for(context)
464
+ return nil if context.nil?
465
+
466
+ raw = streams_presence_member ? streams_presence_member.call(context) : default_presence_member(context)
467
+ return nil unless raw.is_a?(Hash)
468
+
469
+ id = raw[:id]
470
+ return nil if id.nil? || id.to_s.empty?
471
+
472
+ { id: id.to_s, metadata: raw[:metadata] || {} }
473
+ end
474
+
430
475
  # Set the worker capsule list. Accepts:
431
476
  #
432
477
  # String — parsed via Pgbus::Configuration::CapsuleDSL into capsules
@@ -689,6 +734,20 @@ module Pgbus
689
734
 
690
735
  private
691
736
 
737
+ # Built-in presence-member extractor used when no custom
738
+ # `streams_presence_member` is configured. Returns a { id:, metadata: }
739
+ # Hash or nil; #presence_member_for normalizes the id/metadata.
740
+ def default_presence_member(context)
741
+ if context.is_a?(Hash)
742
+ id = context[:member_id] || context[:id]
743
+ return nil if id.nil?
744
+
745
+ { id: id, metadata: context[:metadata] || {} }
746
+ elsif context.respond_to?(:id)
747
+ { id: context.id, metadata: {} }
748
+ end
749
+ end
750
+
692
751
  # Coerce a duration setting value to a positive Numeric.
693
752
  #
694
753
  # Accepts an ActiveSupport::Duration (coerced to Integer seconds via .to_i)
@@ -5,8 +5,6 @@ module Pgbus
5
5
  class AsyncPool
6
6
  attr_reader :capacity
7
7
 
8
- IDLE_WAIT_INTERVAL = 0.01
9
-
10
8
  def initialize(capacity:, on_state_change: nil)
11
9
  @capacity = capacity
12
10
  @on_state_change = on_state_change
@@ -17,6 +15,7 @@ module Pgbus
17
15
  @fatal_error = nil
18
16
  @boot_queue = Thread::Queue.new
19
17
  @pending = Thread::Queue.new
18
+ @wake_rd, @wake_wr = IO.pipe
20
19
 
21
20
  validate_dependencies!
22
21
  @reactor_thread = start_reactor
@@ -32,6 +31,7 @@ module Pgbus
32
31
  reserve_capacity!
33
32
  reserved = true
34
33
  @pending << block
34
+ wake_reactor
35
35
  rescue StandardError
36
36
  restore_capacity if reserved
37
37
  raise
@@ -52,6 +52,7 @@ module Pgbus
52
52
 
53
53
  @shutdown_flag = true
54
54
  end
55
+ wake_reactor
55
56
  end
56
57
 
57
58
  def shutdown?
@@ -96,7 +97,18 @@ module Pgbus
96
97
  @boot_queue << :ready
97
98
 
98
99
  wait_for_executions(semaphore)
99
- wait_for_inflight
100
+ wait_for_inflight(task)
101
+ ensure
102
+ begin
103
+ @wake_rd.close
104
+ rescue IOError
105
+ nil
106
+ end
107
+ begin
108
+ @wake_wr.close
109
+ rescue IOError
110
+ nil
111
+ end
100
112
  end
101
113
  rescue Exception => e
102
114
  register_fatal_error(e)
@@ -110,10 +122,35 @@ module Pgbus
110
122
  schedule_pending(semaphore)
111
123
  break if shutdown? && @pending.empty?
112
124
 
113
- sleep(IDLE_WAIT_INTERVAL) if @pending.empty?
125
+ wait_for_wake if @pending.empty?
114
126
  end
115
127
  end
116
128
 
129
+ # Fiber-aware wait: yields the reactor fiber until the main thread
130
+ # writes a wake byte via wake_reactor. IO#wait_readable integrates
131
+ # with the Async scheduler so other fibers continue running.
132
+ def wait_for_wake
133
+ return if @wake_rd.closed?
134
+
135
+ @wake_rd.wait_readable
136
+ drain_wake_pipe
137
+ rescue IOError, Errno::EBADF
138
+ nil
139
+ end
140
+
141
+ def drain_wake_pipe
142
+ @wake_rd.read_nonblock(256)
143
+ rescue IOError, SystemCallError
144
+ nil
145
+ end
146
+
147
+ # Thread-safe: called from the main thread to wake the reactor fiber.
148
+ def wake_reactor
149
+ @wake_wr.write_nonblock(".")
150
+ rescue IO::WaitWritable, IOError, Errno::EBADF, Errno::EPIPE
151
+ nil
152
+ end
153
+
117
154
  def schedule_pending(semaphore)
118
155
  while (block = next_pending)
119
156
  semaphore.async do
@@ -160,6 +197,7 @@ module Pgbus
160
197
  @available_capacity += 1
161
198
  @available_capacity.positive?
162
199
  end
200
+ wake_reactor
163
201
  @on_state_change&.call if should_notify
164
202
  end
165
203
 
@@ -174,8 +212,8 @@ module Pgbus
174
212
  raise error if error
175
213
  end
176
214
 
177
- def wait_for_inflight
178
- sleep(IDLE_WAIT_INTERVAL) while inflight?
215
+ def wait_for_inflight(task)
216
+ task.sleep(0.01) while inflight?
179
217
  end
180
218
 
181
219
  def inflight?
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent"
4
+
5
+ module Pgbus
6
+ module Streams
7
+ # Publish-side coalescing for high-frequency broadcasts (issue #171).
8
+ #
9
+ # A chatty reactive component — a live cursor, a typing indicator, a
10
+ # progress bar — can fan out many small broadcasts per second. For
11
+ # per-keystroke / per-frame updates that's wasteful: every frame
12
+ # becomes a PGMQ insert (or a NOTIFY) and a fan-out to every connection.
13
+ #
14
+ # The Coalescer batches per (stream, target) within a short window and
15
+ # flushes only the *latest* payload, so superseded frames never hit the
16
+ # bus at all. This is last-write-wins and is only safe for idempotent
17
+ # actions (replace / update of a stable target) — which is exactly the
18
+ # high-frequency case. It is strictly opt-in (`coalesce:` on broadcast).
19
+ #
20
+ # Debounce semantics: the FIRST submit for a (stream, target) schedules
21
+ # a flush `window_ms` later; subsequent submits within that window only
22
+ # overwrite the buffered payload. So latency is bounded to one window
23
+ # and a continuous stream of updates can't starve the flush (it is a
24
+ # trailing-edge-with-max-wait debounce, not a resettable one).
25
+ #
26
+ # Thread-safe: many request threads may submit concurrently. The buffer
27
+ # and the per-key pending-flush set are guarded by a single mutex; the
28
+ # flush itself runs off the mutex on the scheduler's thread.
29
+ class Coalescer
30
+ # Default coalescing window when `coalesce: true` is passed without an
31
+ # explicit millisecond value.
32
+ DEFAULT_WINDOW_MS = 50
33
+
34
+ Entry = Struct.new(:payload, :opts)
35
+
36
+ # scheduler: responds to `schedule(delay_seconds) { ... }`. Defaults
37
+ # to a Concurrent::ScheduledTask-backed scheduler.
38
+ # flush: ->(stream_name:, target:, payload:, opts:) called once per
39
+ # window per key with the latest buffered frame.
40
+ def initialize(flush:, scheduler: nil)
41
+ @flush = flush
42
+ @scheduler = scheduler || ScheduledTaskScheduler.new
43
+ @mutex = Mutex.new
44
+ @buffer = {} # key => Entry (latest)
45
+ @pending = {} # key => true while a flush is scheduled
46
+ end
47
+
48
+ # Buffers a frame for (stream_name, target). Overwrites any frame
49
+ # already buffered for the same key within the current window. The
50
+ # first submit per window schedules the flush.
51
+ def submit(stream_name:, target:, payload:, opts:, window_ms: DEFAULT_WINDOW_MS)
52
+ key = [stream_name, target]
53
+ schedule = false
54
+
55
+ @mutex.synchronize do
56
+ @buffer[key] = Entry.new(payload, opts)
57
+ unless @pending[key]
58
+ @pending[key] = true
59
+ schedule = true
60
+ end
61
+ end
62
+
63
+ @scheduler.schedule(window_ms / 1000.0) { flush_key(key, stream_name, target) } if schedule
64
+ end
65
+
66
+ private
67
+
68
+ def flush_key(key, stream_name, target)
69
+ entry = @mutex.synchronize do
70
+ @pending.delete(key)
71
+ @buffer.delete(key)
72
+ end
73
+ return unless entry
74
+
75
+ @flush.call(stream_name: stream_name, target: target, payload: entry.payload, opts: entry.opts)
76
+ end
77
+
78
+ # Default scheduler backed by Concurrent::ScheduledTask. Kept as a
79
+ # tiny adapter so the Coalescer can be unit-tested with a synchronous
80
+ # fake scheduler (no real timers, no sleeps).
81
+ class ScheduledTaskScheduler
82
+ def schedule(delay_seconds, &)
83
+ Concurrent::ScheduledTask.execute(delay_seconds, &)
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
4
+
3
5
  module Pgbus
4
6
  module Streams
5
7
  # Encodes Server-Sent Events frames per https://html.spec.whatwg.org/multipage/server-sent-events.html.
@@ -29,13 +31,30 @@ module Pgbus
29
31
  raise ArgumentError, "id is required" if id.nil?
30
32
  raise ArgumentError, "event is required" if event.nil? || event.to_s.empty?
31
33
 
32
- "id: #{id}\nevent: #{event}\ndata: #{strip_newlines(data.to_s)}\n\n"
34
+ # Strip newlines from BOTH event and data, not just data: each is
35
+ # interpolated into its own SSE field line, so an unescaped \r/\n in
36
+ # either would terminate the field early and let a crafted value
37
+ # inject extra SSE fields (a forged id:/data:) into the frame.
38
+ "id: #{id}\nevent: #{strip_newlines(event.to_s)}\ndata: #{strip_newlines(data.to_s)}\n\n"
33
39
  end
34
40
 
35
41
  def self.comment(text)
36
42
  ": #{strip_newlines(text.to_s)}\n\n"
37
43
  end
38
44
 
45
+ # Emits a `pgbus:connected` frame carrying the server-minted
46
+ # connection id as JSON. Sent once, right after the open handshake,
47
+ # so the page can read its own connection id and send it back as
48
+ # `X-Pgbus-Connection` on action requests (actor-echo suppression,
49
+ # issue #165). Deliberately omits an `id:` line: this is connection
50
+ # metadata, not a broadcast, and giving it a cursor id would corrupt
51
+ # the client's Last-Event-ID replay position on reconnect.
52
+ def self.connected(id:)
53
+ raise ArgumentError, "id is required" if id.nil? || id.to_s.empty?
54
+
55
+ "event: pgbus:connected\ndata: #{JSON.generate({ connectionId: id.to_s })}\n\n"
56
+ end
57
+
39
58
  def self.retry_directive(milliseconds)
40
59
  unless milliseconds.is_a?(Integer) && !milliseconds.negative?
41
60
  raise ArgumentError, "retry must be a non-negative integer (got #{milliseconds.inspect})"
@@ -63,9 +63,42 @@ module Pgbus
63
63
  # `to_stream_key`/`to_gid_param` implementation that forgot to
64
64
  # sanitize) raise an ArgumentError at the call site.
65
65
  def stream_key(*parts, digest_bits: DEFAULT_DIGEST_BITS)
66
- fragments = Array(parts).flatten.map { |part| normalize(part, digest_bits: digest_bits) }
66
+ flattened = Array(parts).flatten
67
+
68
+ # Idempotency for an already-built key: a single String argument
69
+ # is treated as a pre-built pgbus stream key and returned
70
+ # unchanged (after the budget check). This lets a consumer hold
71
+ # one `stream_key` value and pass it to both `turbo_stream_from`
72
+ # and the broadcaster without the colon separator guard raising
73
+ # on the second call. The guard only protects against ambiguous
74
+ # *joins* (`stream_key('a:b', :c)` vs `stream_key('a', 'b:c')`),
75
+ # and there is no second fragment here to collapse against, so the
76
+ # hazard cannot arise. Symbols and records are NOT keys — a colon
77
+ # in those never came from `stream_key` and stays a mistake.
78
+ return stream_key!(flattened.first) if flattened.length == 1 && flattened.first.is_a?(String)
79
+
80
+ fragments = flattened.map { |part| normalize(part, digest_bits: digest_bits) }
67
81
  fragments.each { |fragment| reject_colons!(fragment) }
68
- key = fragments.join(":")
82
+ validate_budget!(fragments.join(":"))
83
+ end
84
+
85
+ # Accepts an already-built stream key verbatim, skipping the
86
+ # per-fragment colon guard (a pre-built key legitimately contains
87
+ # ':' separators). Still enforces the queue-name budget so an
88
+ # oversized key fails at the call site rather than deep inside
89
+ # Client#ensure_stream_queue. Use this when you hold a key string
90
+ # and want to be explicit that no re-keying should happen — e.g.
91
+ # passing the same value to `turbo_stream_from` and a broadcaster.
92
+ def stream_key!(key)
93
+ raise ArgumentError, "stream_key! key must be a String, got #{key.class}" unless key.is_a?(String)
94
+
95
+ validate_budget!(key)
96
+ end
97
+
98
+ # Returns the key when it fits the pgbus queue-name budget; raises
99
+ # ArgumentError with an actionable message otherwise. Shared by
100
+ # `stream_key` and `stream_key!` so both paths fail identically.
101
+ def validate_budget!(key)
69
102
  budget = queue_name_budget
70
103
  return key if key.length <= budget
71
104