pgbus 0.16.5 → 0.16.6

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: 4382e9b750a7e3538c2ba640aa476bb914cd1c1deba00fda429510ea1dcdc851
4
- data.tar.gz: bc8bff2b3b08ccc6de7d67c677a8797be4d0a1b9f65019a9728d0531ad5a830a
3
+ metadata.gz: cddde0da7d83fc84f54404d10bcdbfccb06f25af74379b560ac4f9bb892f0041
4
+ data.tar.gz: 44eba7a5a2bbe87659f388c7fb10c5a7b7577a1f045bb8d48c527cfbf9266df5
5
5
  SHA512:
6
- metadata.gz: 9630731c5e8b4ef6bb9db80e1e950ad6abee7748e49868fb613645b0d16469f477a6b0fe819427135a6bdf85589fb518e6204f80a3a20810939ed1c2038238a7
7
- data.tar.gz: b4c5b23b4e4513b5447985ab96b1578a3a1931fda6b3b4700338b71d7d822a989cda6498670a31e7ef30848f69f4075b23a2f018764072f51b6e91ae258d8445
6
+ metadata.gz: 3bb680972106246ea3a16265730eb8b0567944e80323cbca8a9f68f7b7302b8afa2087cfee74e0fa99b695f038fd5e2104141971096ac8d76b236437ce147e60
7
+ data.tar.gz: cf822271a70b7ec8bcbbca0591838179678113c2b19849a87b4442cf5e0ccd3b2bac1d7a92e8820596c839ff1005c82d0c0ab45d47ddad2df1bae8094b0a9208
data/CHANGELOG.md CHANGED
@@ -2,12 +2,17 @@
2
2
 
3
3
  ### Added
4
4
 
5
+ - **`coalesce:` now works on the `Turbo::StreamsChannel` broadcast path.** `Pgbus::Streams::Stream#broadcast` has accepted `coalesce:` since #171, but the Turbo patch forwarded only four options (`durable:`, `exclude:`, `visible_to:`, `event:`), so nothing that broadcasts through `Turbo::StreamsChannel.broadcast_*_to` — every `Turbo::Broadcastable` model, and phlex-reactive's `Streamable.broadcast_to` — could debounce. A background job settling a 177-row collection therefore pushed 177 idempotent replaces of the same count badge at every peer. Any targeted helper now takes `coalesce:` (`@order.broadcast_replace_to :account, coalesce: 100`), as does a direct channel call (`Turbo::StreamsChannel.broadcast_replace_to(@account, target: "order-count", coalesce: true)`), with the same semantics as the direct API: opt-in, per `(stream, target)`, last-write-wins, `true` = the 50ms default window, an integer = the window in ms. Two things the option's plumbing needed beyond a fifth thread-local: the coalescing key, which dedupes on `(stream, target)` while `broadcast_stream_to` only ever sees the rendered `content:` — it is now resolved from the frame's own `target:`/`targets:` through the same `ActionView::RecordIdentifier.dom_id` conversion Turbo applies, so a record target keys on `order_7` rather than on a per-instance `#to_s`; and extraction of the pgbus options at the channel itself, since a direct `Turbo::StreamsChannel` call never passes through `Turbo::Broadcastable` and so previously leaked `durable: true` (and now `coalesce:`) into Turbo's renderer. `broadcast_refresh_to` and `broadcast_render_to` carry no target and so cannot coalesce — they raise the existing actionable "requires target:" error rather than guessing a key — and the `_later_to` variants remain out of scope, as the thread-local cannot follow into a job. The wire is byte-identical when `coalesce:` is absent. Closes #465.
6
+
5
7
  - **Concurrency is now visible: a section on the Locks page, three gauges, a home stat card and an MCP tool.** `limits_concurrency … on_conflict: :block` became durable in #460 — a parked job is never dropped and a key never runs past `to:` — but nothing showed it, so an operator whose per-record pipeline looked "stuck" had no way to see that the key's slot was held, how many jobs were parked behind it, or how long the oldest had waited, short of SQL. `pgbus_semaphores` and `pgbus_blocked_executions` appeared on no page, in no gauge, in no tool. One new read, `Web::DataSource#concurrency_stats`, now feeds four surfaces: the Locks page's **Concurrency** section (parked jobs, oldest wait, slots held, keys at limit, then a row per key with value / limit, lease state, parked count and oldest wait, auto-refreshing like the other dashboard panels); a **Parked jobs** card on the dashboard home; three unlabelled gauges on both `/pgbus/api/metrics` and the AppSignal probe (`pgbus_concurrency_blocked_executions`, `pgbus_concurrency_blocked_oldest_age_seconds`, `pgbus_concurrency_slots_held` — keys are per-record, so a per-key label would be an unbounded series); and a read-only `pgbus_concurrency` MCP tool returning the same data. The key rows come from a FULL OUTER JOIN, not a LEFT one: a key can have parked jobs and no semaphore (its holder died and the sweep removed the row) or a semaphore and nothing parked, and both shapes matter to an operator. Each row carries two guarded escape hatches. **Release** drops the semaphore and promotes through `Concurrency::BlockedExecution.promote_next`, so it takes each slot through the same guarded upsert an enqueue uses and can never reintroduce the over-admission #460 closed; when the lease is still fresh the confirm says so, because a job is then probably still running and releasing lets another start beside it. **Discard parked** drops a single key's parked jobs, naming the exact count in the confirm, and resolves the bookkeeping those jobs will never resolve themselves — a parked batch child is marked failed so its batch stops waiting and `on_failure` fires (#413), and an `:until_executed` uniqueness lock is released rather than orphaned (#423); each discard logs a warning naming the job and emits `pgbus.blocked_execution_discarded`. There is deliberately no cross-key "discard all parked": not losing work is the whole point of `:block`. Nothing is added to the enqueue or execute path. Refs #461.
6
8
 
7
9
  - **The batches list and a batch's progress panel now refresh themselves.** Batches was the only dashboard list whose `turbo-frame` was missing `data-auto-refresh`, and the batch detail page had no frame at all, so watching a batch drain meant reloading by hand. Both now poll on the existing `web_refresh_interval` (5s, pauses while you interact) like every other list — the batch progress panel is served by `GET /batches/:id?frame=progress`. This reuses the dashboard's turbo-frame polling rather than the SSE stack in `lib/pgbus/web/stream_app.rb`: that is the host-app-facing stream feature, and pointing it at the dashboard would open a PG `LISTEN` connection per viewer to move four integers.
8
10
 
9
11
  ### Fixed
10
12
 
13
+ - **A dropped ActiveRecord socket no longer pages the host app for an event that was handled successfully.** EventBus consumers are long-lived threads holding a leased AR connection, and a pooler restart, an admin disconnect or a brief failover can kill that socket at any point. Rails reconnects most statements transparently, but `Relation#update_all` is marked `allow_retry: false` — and that is exactly the phase-2 claim stamp in `Handler#complete_claim!`, the one AR write that happens *after* `handle` has already returned. A drop there surfaced as `ActiveRecord::ConnectionFailed` ("PQconsumeInput() SSL error: unexpected eof while reading") on a message whose work was done, so the host app's exception tracker paged for a false failure and PGMQ redelivered the event for a re-run. `EventBus::StaleConnectionRetry` now reconnects this thread's lease (via `ConnectionPool#active_connection?`, the accessor that exists across the supported Rails range) and repeats the stamp once; a second drop still raises, leaving the message to VT redelivery. Only the leased connections are reconnected — `clear_all_connections!` would yank sockets out from under sibling consumers in the same process, turning one recoverable drop into many. The retryable patterns are deliberately *broader* than `Client::STALE_CONNECTION_PATTERNS` and for the opposite reason: that list excludes mid-flight drops because a half-committed enqueue would duplicate a message, whereas this only ever repeats an idempotent `SET completed_at = <now>`. Phase 1 (`claim_idempotency?`) is deliberately not wrapped — its INSERT may have committed before the socket died, and on a legacy schema the retry's empty `result.rows` would read as "another consumer owns this claim", turning a recoverable drop into a silently skipped event.
14
+
15
+
11
16
  - **The AppSignal probe's gauges and every MCP tool response were frozen at their first read.** `Web::DataSource` memoizes `queues_with_metrics` for the life of the instance — correct for the dashboard, which builds one instance per request, but the AppSignal `Probe::Runner` holds a single DataSource for the life of the process and `MCP::Server.build` injects one into the server context, so both replayed the first read forever: queue depth in AppSignal never moved after the first minute, and `pgbus_queues` returned the same snapshot for every call in a session. `DataSource#reset_cache!` drops the memos, and both long-lived consumers call it — the probe once per iteration, `MCP::BaseTool.data_source_from` once per tool call. Found while reviewing the concurrency aggregates added in the same release, which memoize the same way.
12
17
 
13
18
  - **`limits_concurrency … on_conflict: :block` now constrains a job without ever losing it, and never runs more than `to:` at once.** The same bug class solid_queue closed in rails/solid_queue#712 and #761, checked against pgbus's implementation. Four defects, each with a regression spec:
@@ -158,12 +158,26 @@ module Pgbus
158
158
  # already cached at claim time). If this write fails, process!'s rescue
159
159
  # re-raises, the consumer leaves the message for VT redelivery, and the
160
160
  # still-pending claim re-runs — at-least-once, never a silent drop.
161
+ #
162
+ # Wrapped in StaleConnectionRetry because this is the one AR write that
163
+ # happens AFTER handle() has already succeeded: a socket dropped here
164
+ # costs the host app a paging exception for work that was in fact done.
165
+ # The stamp is an idempotent `SET completed_at = <now>`, so repeating a
166
+ # statement that may already have committed is safe.
167
+ #
168
+ # Phase 1 (claim_idempotency?) is deliberately NOT wrapped. Its INSERT
169
+ # may have committed before the socket died, and on a legacy schema the
170
+ # retry's empty `result.rows` would read as "someone else owns this
171
+ # claim" and return false — turning a recoverable drop into a silently
172
+ # skipped event. VT redelivery is the correct recovery there.
161
173
  def complete_claim!(event_id)
162
174
  return unless ProcessedEvent.completion_column?
163
175
 
164
- ProcessedEvent
165
- .where(event_id: event_id, handler_class: self.class.name)
166
- .update_all(completed_at: Time.now.utc)
176
+ StaleConnectionRetry.call(context: self.class.name) do
177
+ ProcessedEvent
178
+ .where(event_id: event_id, handler_class: self.class.name)
179
+ .update_all(completed_at: Time.now.utc)
180
+ end
167
181
  self.class.dedup_cache.mark!(dedup_key(event_id))
168
182
  end
169
183
 
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module EventBus
5
+ # Reconnect + one retry for an ActiveRecord socket that died underneath a
6
+ # long-lived consumer thread.
7
+ #
8
+ # A consumer holds its leased connection for the life of the message, and a
9
+ # pooler restart (PgBouncer `server_idle_timeout`), an admin disconnect or a
10
+ # brief failover can kill that socket at any point. Rails reconnects most
11
+ # statements transparently, but `Relation#update_all` is marked
12
+ # `allow_retry: false`, so the drop propagates — and the only call that
13
+ # matters here, the phase-2 claim stamp, is exactly an `update_all`. The
14
+ # host app saw a paging exception on a message whose handler had in fact
15
+ # succeeded.
16
+ #
17
+ # Deliberately narrower than `Client::STALE_CONNECTION_PATTERNS`, and for
18
+ # the opposite reason. That list excludes mid-flight drops because a
19
+ # half-committed *enqueue* would duplicate a message on retry. This one
20
+ # only ever wraps an idempotent `UPDATE ... SET completed_at = <now>`, so a
21
+ # statement that may already have committed is safe to repeat — which is
22
+ # what makes the mid-flight shapes retryable here and not there.
23
+ module StaleConnectionRetry
24
+ # The socket dying under a statement, not a server refusing us. A refused
25
+ # or timed-out connection is an outage: retrying it in-process buys
26
+ # nothing and hides the outage behind a doubled statement timeout.
27
+ #
28
+ # "ssl syscall error" is libpq's wording when the peer vanished without a
29
+ # TLS close_notify — the same drop as "unexpected eof while reading",
30
+ # reported from the syscall layer instead. It is in
31
+ # Client::STALE_CONNECTION_PATTERNS for the same reason.
32
+ TRANSIENT_DROP = /
33
+ PQconsumeInput|
34
+ server\ closed\ the\ connection\ unexpectedly|
35
+ unexpected\ eof\ while\ reading|
36
+ ssl\ syscall\ error
37
+ /ix
38
+
39
+ module_function
40
+
41
+ # Runs the block, and on a transient drop reconnects this thread's lease
42
+ # and runs it exactly once more. A second drop raises: the socket is not
43
+ # coming back inside this attempt, and PGMQ's visibility timeout is the
44
+ # right recovery — it redelivers to a healthy consumer.
45
+ #
46
+ # context — an identifier for the log line (the handler class name).
47
+ def call(context: nil, &block)
48
+ block.call
49
+ rescue ActiveRecord::ConnectionFailed => e
50
+ raise unless transient_drop?(e)
51
+
52
+ reconnect_leased!
53
+ Pgbus.logger.warn do
54
+ "[Pgbus::EventBus] Retrying after stale ActiveRecord connection drop" \
55
+ "#{" (#{context})" if context}: #{e.message}"
56
+ end
57
+ block.call
58
+ end
59
+
60
+ def transient_drop?(error)
61
+ return false unless error.is_a?(ActiveRecord::ConnectionFailed)
62
+
63
+ [error.message, error.cause&.message].compact.any? { |message| TRANSIENT_DROP.match?(message) }
64
+ end
65
+
66
+ # Only the connections this thread has actually leased.
67
+ # `clear_all_connections!` would yank sockets out from under sibling
68
+ # consumers sharing the process, turning one recoverable drop into many.
69
+ # `active_connection?` returns the lease or nil, and is the accessor this
70
+ # gem uses everywhere (Streams#current_open_transaction documents why we
71
+ # never reach for `ActiveRecord::Base.connection`). Its `active_connection`
72
+ # alias is `:nodoc:` and does not exist at all before Rails 7.2 — below
73
+ # our floor, so calling it would raise NoMethodError on the one path that
74
+ # exists to recover from an error.
75
+ def reconnect_leased!
76
+ ActiveRecord::Base.connection_handler.each_connection_pool(:all) do |pool|
77
+ connection = pool.active_connection?
78
+ next unless connection
79
+
80
+ connection.reconnect!
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module Streams
5
+ # The thread-local channel that carries pgbus-specific broadcast options
6
+ # from a `Turbo::Broadcastable` / `Turbo::StreamsChannel` call site down to
7
+ # `TurboBroadcastable#broadcast_stream_to`, which is the only place with a
8
+ # `Pgbus::Streams::Stream` to hand them to.
9
+ #
10
+ # turbo-rails' broadcast helpers funnel everything through
11
+ # `broadcast_stream_to(*streamables, content:)` — a signature with nowhere
12
+ # to put `durable:`, `exclude:`, `visible_to:`, `event:` or `coalesce:`.
13
+ # Rather than fork turbo's whole helper surface, the patches pull those
14
+ # kwargs out before calling `super` and stash them here for the duration of
15
+ # the broadcast.
16
+ #
17
+ # Both patches (`BroadcastableOverride` on the model concern and
18
+ # `TurboBroadcastable` on the channel) share this module so there is one
19
+ # definition of which keys exist and how they are saved/restored.
20
+ module BroadcastOpts
21
+ # Options a caller may pass to any Turbo broadcast helper. They are
22
+ # deleted from the kwargs so they never reach turbo-rails' renderer.
23
+ KEYS = %i[durable exclude visible_to event coalesce].freeze
24
+
25
+ # `coalesce_target` is not a caller-facing kwarg — it is derived from the
26
+ # broadcast's own `target:`/`targets:` by the channel patch, because
27
+ # coalescing keys on `(stream, target)` and `broadcast_stream_to` never
28
+ # sees the target otherwise.
29
+ THREAD_LOCALS = {
30
+ durable: :pgbus_broadcast_durable,
31
+ exclude: :pgbus_broadcast_exclude,
32
+ visible_to: :pgbus_broadcast_visible_to,
33
+ event: :pgbus_broadcast_event,
34
+ coalesce: :pgbus_broadcast_coalesce,
35
+ coalesce_target: :pgbus_broadcast_coalesce_target
36
+ }.freeze
37
+
38
+ # Removes the pgbus options from `kwargs` (mutating it) and returns them.
39
+ # Keys the caller did not pass are absent from the result, so an outer
40
+ # `BroadcastOpts.with` block's values survive an inner broadcast that
41
+ # doesn't set them.
42
+ def self.extract!(kwargs)
43
+ KEYS.each_with_object({}) do |key, opts|
44
+ opts[key] = kwargs.delete(key) if kwargs.key?(key)
45
+ end
46
+ end
47
+
48
+ # Sets the thread-locals for the given options for the duration of the
49
+ # block, restoring the previous values afterwards (nesting-safe), even on
50
+ # error. Only the keys passed are touched.
51
+ def self.with(**opts)
52
+ previous = {}
53
+
54
+ opts.each do |key, value|
55
+ tl_key = THREAD_LOCALS.fetch(key)
56
+ previous[tl_key] = Thread.current[tl_key]
57
+ Thread.current[tl_key] = value
58
+ end
59
+
60
+ yield
61
+ ensure
62
+ previous.each { |tl_key, value| Thread.current[tl_key] = value }
63
+ end
64
+
65
+ def self.[](key)
66
+ Thread.current[THREAD_LOCALS.fetch(key)]
67
+ end
68
+ end
69
+ end
70
+ end
@@ -40,7 +40,7 @@ module Pgbus
40
40
  # don't understand. We pull them out of kwargs (so they never reach
41
41
  # turbo-rails' renderer) and thread them to broadcast_stream_to via
42
42
  # thread-locals, mirroring the original durable: shim.
43
- PGBUS_BROADCAST_OPTS = %i[durable exclude visible_to event].freeze
43
+ PGBUS_BROADCAST_OPTS = BroadcastOpts::KEYS
44
44
 
45
45
  BROADCAST_METHODS.each do |method_name|
46
46
  define_method(method_name) do |*streamables, **kwargs|
@@ -128,32 +128,19 @@ module Pgbus
128
128
  private
129
129
 
130
130
  def extract_pgbus_broadcast_opts(kwargs)
131
- PGBUS_BROADCAST_OPTS.each_with_object({}) do |key, opts|
132
- opts[key] = kwargs.delete(key) if kwargs.key?(key)
133
- end
131
+ BroadcastOpts.extract!(kwargs)
134
132
  end
135
133
 
136
134
  # Set the pgbus broadcast thread-locals for the duration of the block,
137
135
  # restoring previous values afterwards (nested/concurrent-safe). Only
138
136
  # keys actually passed are touched, so unrelated outer broadcasts keep
139
137
  # their values.
140
- def with_pgbus_broadcast_opts(durable: :__unset__, exclude: :__unset__, visible_to: :__unset__, event: :__unset__)
141
- previous = {}
142
- set = lambda do |tl_key, value|
143
- next if value == :__unset__
144
-
145
- previous[tl_key] = Thread.current[tl_key]
146
- Thread.current[tl_key] = value
147
- end
148
-
149
- set.call(:pgbus_broadcast_durable, durable)
150
- set.call(:pgbus_broadcast_exclude, exclude)
151
- set.call(:pgbus_broadcast_visible_to, visible_to)
152
- set.call(:pgbus_broadcast_event, event)
138
+ def with_pgbus_broadcast_opts(durable: :__unset__, exclude: :__unset__, visible_to: :__unset__,
139
+ event: :__unset__, coalesce: :__unset__, &)
140
+ opts = { durable: durable, exclude: exclude, visible_to: visible_to, event: event, coalesce: coalesce }
141
+ opts.reject! { |_key, value| value == :__unset__ }
153
142
 
154
- yield
155
- ensure
156
- previous.each { |tl_key, value| Thread.current[tl_key] = value }
143
+ BroadcastOpts.with(**opts, &)
157
144
  end
158
145
  end
159
146
  end
@@ -34,9 +34,57 @@ module Pgbus
34
34
  # long as `Turbo.signed_stream_verifier_key` is set, which the Rails
35
35
  # app is already responsible for).
36
36
  module TurboBroadcastable
37
+ # Every targeted broadcast helper (`broadcast_replace_to`,
38
+ # `broadcast_append_to`, `broadcast_remove_to`, …) funnels through
39
+ # `broadcast_action_to`, so this is the one place that sees both the
40
+ # caller's kwargs and the `target:`/`targets:` the frame will carry.
41
+ #
42
+ # It does two things the model-level `BroadcastableOverride` can't:
43
+ #
44
+ # 1. Extracts the pgbus options from a *direct* channel call —
45
+ # `Turbo::StreamsChannel.broadcast_replace_to(..., coalesce: true)` —
46
+ # which never passes through `Turbo::Broadcastable` at all (this is
47
+ # the path phlex-reactive's `Streamable.broadcast_to` takes).
48
+ # 2. Records the coalescing key. `coalesce:` dedupes on
49
+ # `(stream, target)`, but `broadcast_stream_to` only receives the
50
+ # rendered `content:` — the target is gone by then. We resolve it the
51
+ # same way turbo will (`convert_to_turbo_stream_dom_id`), so a record
52
+ # target keys on its stable dom_id rather than its object id.
53
+ #
54
+ # Resolving the key is skipped entirely unless coalescing was actually
55
+ # requested, so the uncoalesced path stays byte-identical and free.
56
+ def broadcast_action_to(*streamables, action:, target: nil, targets: nil, **rendering)
57
+ opts = BroadcastOpts.extract!(rendering)
58
+
59
+ coalesce = opts.key?(:coalesce) ? opts[:coalesce] : BroadcastOpts[:coalesce]
60
+ opts[:coalesce_target] = pgbus_coalesce_target(target, targets) if coalesce
61
+
62
+ BroadcastOpts.with(**opts) do
63
+ super(*streamables, action: action, target: target, targets: targets, **rendering)
64
+ end
65
+ end
66
+
67
+ # The two helpers that don't pass through `broadcast_action_to`. They
68
+ # carry no target, so they can't coalesce (`Stream#broadcast` raises an
69
+ # actionable error, same as `Pgbus.stream(x).broadcast(coalesce:)`
70
+ # without one) — but they still need their pgbus options pulled out of
71
+ # the kwargs, or a direct channel call leaks `durable: true` into
72
+ # turbo's renderer and it ends up as an HTML attribute.
73
+ def broadcast_refresh_to(*streamables, **attributes)
74
+ BroadcastOpts.with(**BroadcastOpts.extract!(attributes)) do
75
+ super(*streamables, **attributes)
76
+ end
77
+ end
78
+
79
+ def broadcast_render_to(*streamables, **rendering)
80
+ BroadcastOpts.with(**BroadcastOpts.extract!(rendering)) do
81
+ super(*streamables, **rendering)
82
+ end
83
+ end
84
+
37
85
  def broadcast_stream_to(*streamables, content:)
38
86
  name = stream_name_from(streamables)
39
- override = Thread.current[:pgbus_broadcast_durable]
87
+ override = BroadcastOpts[:durable]
40
88
  # When no explicit thread-local override is present, let the config
41
89
  # resolver decide: it checks `streams_durable_patterns` first (exact
42
90
  # string or regex match), then falls back to
@@ -46,11 +94,24 @@ module Pgbus
46
94
  durable = override.nil? ? Pgbus.configuration.stream_durable?(name) : override
47
95
  Pgbus.stream(name, durable: durable).broadcast(
48
96
  content,
49
- exclude: Thread.current[:pgbus_broadcast_exclude],
50
- visible_to: Thread.current[:pgbus_broadcast_visible_to],
51
- event: Thread.current[:pgbus_broadcast_event]
97
+ exclude: BroadcastOpts[:exclude],
98
+ visible_to: BroadcastOpts[:visible_to],
99
+ event: BroadcastOpts[:event],
100
+ coalesce: BroadcastOpts[:coalesce],
101
+ target: BroadcastOpts[:coalesce_target]
52
102
  )
53
103
  end
104
+
105
+ private
106
+
107
+ # The coalescing key, resolved exactly as turbo resolves the rendered
108
+ # `target=`/`targets=` attribute. `targets:` (a CSS selector) is the
109
+ # fallback turbo itself uses when `target:` is absent; we drop the `#`
110
+ # selector prefix because the key is never rendered — it only has to be
111
+ # stable and distinct.
112
+ def pgbus_coalesce_target(target, targets)
113
+ convert_to_turbo_stream_dom_id(target) || convert_to_turbo_stream_dom_id(targets)
114
+ end
54
115
  end
55
116
 
56
117
  # Apply the patch to Turbo::StreamsChannel's singleton class. Idempotent:
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.16.5"
4
+ VERSION = "0.16.6"
5
5
  end
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.16.5
4
+ version: 0.16.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson
@@ -294,6 +294,7 @@ files:
294
294
  - lib/pgbus/event_bus/handler.rb
295
295
  - lib/pgbus/event_bus/publisher.rb
296
296
  - lib/pgbus/event_bus/registry.rb
297
+ - lib/pgbus/event_bus/stale_connection_retry.rb
297
298
  - lib/pgbus/event_bus/subscriber.rb
298
299
  - lib/pgbus/execution_pools.rb
299
300
  - lib/pgbus/execution_pools/async_pool.rb
@@ -375,6 +376,7 @@ files:
375
376
  - lib/pgbus/serializer.rb
376
377
  - lib/pgbus/stat_buffer.rb
377
378
  - lib/pgbus/streams.rb
379
+ - lib/pgbus/streams/broadcast_opts.rb
378
380
  - lib/pgbus/streams/broadcastable_override.rb
379
381
  - lib/pgbus/streams/coalescer.rb
380
382
  - lib/pgbus/streams/cursor.rb