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 +4 -4
- data/CHANGELOG.md +5 -0
- data/lib/pgbus/event_bus/handler.rb +17 -3
- data/lib/pgbus/event_bus/stale_connection_retry.rb +85 -0
- data/lib/pgbus/streams/broadcast_opts.rb +70 -0
- data/lib/pgbus/streams/broadcastable_override.rb +7 -20
- data/lib/pgbus/streams/turbo_broadcastable.rb +65 -4
- data/lib/pgbus/version.rb +1 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cddde0da7d83fc84f54404d10bcdbfccb06f25af74379b560ac4f9bb892f0041
|
|
4
|
+
data.tar.gz: 44eba7a5a2bbe87659f388c7fb10c5a7b7577a1f045bb8d48c527cfbf9266df5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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 =
|
|
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
|
-
|
|
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__,
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
50
|
-
visible_to:
|
|
51
|
-
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
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.
|
|
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
|