phlex-reactive 0.13.0 → 0.13.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 +4 -4
- data/CHANGELOG.md +113 -7
- data/README.md +244 -5
- data/app/controllers/phlex/reactive/actions_controller.rb +15 -1
- data/lib/phlex/reactive/collections.rb +144 -0
- data/lib/phlex/reactive/pending.rb +337 -0
- data/lib/phlex/reactive/reply.rb +37 -0
- data/lib/phlex/reactive/response.rb +109 -68
- data/lib/phlex/reactive/settle.rb +170 -0
- data/lib/phlex/reactive/settles.rb +309 -0
- data/lib/phlex/reactive/streamable.rb +146 -3
- data/lib/phlex/reactive/version.rb +1 -1
- data/lib/phlex/reactive.rb +35 -0
- metadata +5 -1
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phlex
|
|
4
|
+
module Reactive
|
|
5
|
+
# The job-side reply builder (issue #248) — what `reactive_settle` yields.
|
|
6
|
+
#
|
|
7
|
+
# reactive_settle do |s|
|
|
8
|
+
# if result.success?
|
|
9
|
+
# s.remove(transfer, from: :unreconcilable)
|
|
10
|
+
# else
|
|
11
|
+
# s.replace(transfer)
|
|
12
|
+
# s.flash(:alert, result.error)
|
|
13
|
+
# end
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# The verbs mirror `reply.*` and, critically, route through the SAME
|
|
17
|
+
# Phlex::Reactive::Collections decisions — so a job-side append emits the
|
|
18
|
+
# row AND the count companion AND the 0<->1 empty-state toggle, instead of
|
|
19
|
+
# the bare row `broadcast_to(append:)` gives you. That shared bookkeeping is
|
|
20
|
+
# the whole point: it is what the app can no longer get subtly wrong.
|
|
21
|
+
#
|
|
22
|
+
# Unlike Response, Settle is a MUTABLE accumulator, not a value object: a
|
|
23
|
+
# settle block is imperative (branch on the work's result, add a flash) and
|
|
24
|
+
# every verb returns self so the calls can also be chained.
|
|
25
|
+
#
|
|
26
|
+
# It collects streams; Phlex::Reactive::Settles owns the delivery — one
|
|
27
|
+
# durable message to the actor's shared one-shot stream, plus the optional
|
|
28
|
+
# peers broadcast.
|
|
29
|
+
class Settle
|
|
30
|
+
attr_reader :streams
|
|
31
|
+
|
|
32
|
+
# The rebuilt container — Settles reads it for the peers broadcast (it
|
|
33
|
+
# carries the collection declaration and the size resolver).
|
|
34
|
+
attr_reader :container
|
|
35
|
+
|
|
36
|
+
# `container` is the rebuilt container component (from_identity, off the
|
|
37
|
+
# request thread); `handle` is the Pending::Handle the enqueue captured.
|
|
38
|
+
def initialize(container, handle)
|
|
39
|
+
@container = container
|
|
40
|
+
@handle = handle
|
|
41
|
+
@streams = []
|
|
42
|
+
@peer_ops = []
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Re-render ONE row in place. The work ran and the row is simply different
|
|
46
|
+
# now (a failed re-execution going back to actionable, say). No count or
|
|
47
|
+
# empty-state stream: a replace cannot change the size.
|
|
48
|
+
#
|
|
49
|
+
# Accepts a record (resolved through the collection's row component) or a
|
|
50
|
+
# built Streamable component (its own #id is the target).
|
|
51
|
+
def replace(model, morph: false, effect: nil, **row_kwargs)
|
|
52
|
+
if model.is_a?(Phlex::Reactive::Streamable)
|
|
53
|
+
@streams << model.to_stream_replace(morph:, effect:)
|
|
54
|
+
# A built component carries its own identity, so peers can be handed
|
|
55
|
+
# the same instance — no definition needed.
|
|
56
|
+
@peer_ops << [nil, :replace, model, {}] if @handle.peers
|
|
57
|
+
return self
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
definition = definition!(nil)
|
|
61
|
+
@streams.concat(Phlex::Reactive::Collections.replace_streams(definition, model, effect:, **row_kwargs))
|
|
62
|
+
# A replace is NOT a collection delta (it moves no boundary), but peers
|
|
63
|
+
# still need it: without this, a failed re-execution goes back to
|
|
64
|
+
# actionable for the actor while every other operator keeps the stale
|
|
65
|
+
# row. It rides the ordinary row broadcast, not broadcast_collection_to.
|
|
66
|
+
peer(definition, :replace, model, row_kwargs)
|
|
67
|
+
self
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Remove a row: the row + the count companion + the empty-state restore at
|
|
71
|
+
# the 1->0 boundary. `from:` defaults to the collection reply.pending
|
|
72
|
+
# named, so the common case reads `s.remove(transfer)`.
|
|
73
|
+
def remove(model, from: nil, effect: nil)
|
|
74
|
+
definition = definition!(from)
|
|
75
|
+
@streams.concat(Phlex::Reactive::Collections.remove_streams(definition, @container, model, effect:))
|
|
76
|
+
peer(definition, :remove, model, {})
|
|
77
|
+
self
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Add a row: the row + the count companion + the empty-state clear at the
|
|
81
|
+
# 0->1 boundary.
|
|
82
|
+
def append(model, to: nil, effect: nil, **row_kwargs)
|
|
83
|
+
add(:append, model, to, effect, row_kwargs)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def prepend(model, to: nil, effect: nil, **row_kwargs)
|
|
87
|
+
add(:prepend, model, to, effect, row_kwargs)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# The case that motivated the whole issue: the work moved a record between
|
|
91
|
+
# two lists. Ordered remove-then-append so the size resolvers run against
|
|
92
|
+
# the post-move world in the order a reader expects, and so a row can
|
|
93
|
+
# never be momentarily present in both containers.
|
|
94
|
+
def move(model, from:, to:, effect: nil, **row_kwargs)
|
|
95
|
+
remove(model, from:, effect:)
|
|
96
|
+
append(model, to:, effect:, **row_kwargs)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Refresh ONLY the collection's count companion — for a settle that
|
|
100
|
+
# changed the size without adding or removing a visible row. `name`
|
|
101
|
+
# defaults to the collection reply.pending named.
|
|
102
|
+
def count(name = nil)
|
|
103
|
+
@streams.concat(Phlex::Reactive::Collections.count_streams(definition!(name), @container))
|
|
104
|
+
self
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Tell the operator what happened. The job is where the OUTCOME is known,
|
|
108
|
+
# so this is how the page stops saying "Queued" forever.
|
|
109
|
+
def flash(level, content, target: Phlex::Reactive.flash_target, dismiss_after: nil)
|
|
110
|
+
@streams << Phlex::Reactive::Response.send(:flash_stream, level, content, target:, dismiss_after:)
|
|
111
|
+
self
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Server-pushed client DOM ops, same vocabulary as reply.js. Defaults to
|
|
115
|
+
# the container's id so self-scoped ops just work.
|
|
116
|
+
def js(ops, target: :__default)
|
|
117
|
+
resolved = target == :__default ? @handle.anchor : target
|
|
118
|
+
@streams << Phlex::Reactive::Response.js_stream(ops, target: resolved)
|
|
119
|
+
self
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Escape hatch: raw <turbo-stream> strings, exactly like reply.streams.
|
|
123
|
+
def streams!(*more)
|
|
124
|
+
@streams.concat(more.flatten)
|
|
125
|
+
self
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# The peer deltas recorded alongside the actor's streams (issue #248).
|
|
129
|
+
# Collected rather than broadcast inline so Settles can send them AFTER
|
|
130
|
+
# the actor's message — the actor paid for the click and should not wait
|
|
131
|
+
# behind a fan-out of channel calls.
|
|
132
|
+
attr_reader :peer_ops
|
|
133
|
+
|
|
134
|
+
private
|
|
135
|
+
|
|
136
|
+
def add(action, model, name, effect, row_kwargs)
|
|
137
|
+
definition = definition!(name)
|
|
138
|
+
@streams.concat(
|
|
139
|
+
Phlex::Reactive::Collections.add_streams(definition, @container, model, action, row_kwargs, effect:)
|
|
140
|
+
)
|
|
141
|
+
peer(definition, action, model, row_kwargs)
|
|
142
|
+
self
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Resolve the collection: the explicit keyword, else the one reply.pending
|
|
146
|
+
# named. A settle with neither is a call-site mistake — fail loudly rather
|
|
147
|
+
# than silently emit nothing.
|
|
148
|
+
def definition!(name)
|
|
149
|
+
resolved = name || @handle.collection
|
|
150
|
+
unless resolved
|
|
151
|
+
raise Phlex::Reactive::Error,
|
|
152
|
+
"this settle has no collection to work on — name it (s.remove(record, from: :items)) " \
|
|
153
|
+
"or pass in: to reply.pending so the settle inherits it"
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
Phlex::Reactive::Collections.definition!(@container, resolved)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Record a peer delta (only when reply.pending asked for peers:). The row
|
|
160
|
+
# kwargs ride along: a peer whose row component has a required init kwarg
|
|
161
|
+
# would otherwise raise, and one with an optional kwarg would render
|
|
162
|
+
# different markup than the actor got.
|
|
163
|
+
def peer(definition, action, model, row_kwargs)
|
|
164
|
+
return unless @handle.peers
|
|
165
|
+
|
|
166
|
+
@peer_ops << [definition.name, action, model, row_kwargs]
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phlex
|
|
4
|
+
module Reactive
|
|
5
|
+
# The job half of the async-action lifecycle (issue #248). Include it in a
|
|
6
|
+
# job that does work an action merely ENQUEUED, and settle the UI when the
|
|
7
|
+
# work is actually done:
|
|
8
|
+
#
|
|
9
|
+
# class ReExecuteJob < ApplicationJob
|
|
10
|
+
# include Phlex::Reactive::Settles
|
|
11
|
+
#
|
|
12
|
+
# def perform(transfer_id) # signature UNCHANGED
|
|
13
|
+
# transfer = Transfer.find(transfer_id)
|
|
14
|
+
# result = Transfers::ReExecuteService.call(transfer:)
|
|
15
|
+
#
|
|
16
|
+
# reactive_settle do |s|
|
|
17
|
+
# if result.success?
|
|
18
|
+
# s.remove(transfer, from: :unreconcilable)
|
|
19
|
+
# else
|
|
20
|
+
# s.replace(transfer)
|
|
21
|
+
# s.flash(:alert, result.error)
|
|
22
|
+
# end
|
|
23
|
+
# end
|
|
24
|
+
# end
|
|
25
|
+
# end
|
|
26
|
+
#
|
|
27
|
+
# ## The handle rides ActiveJob metadata
|
|
28
|
+
#
|
|
29
|
+
# `reply.pending` installs a Pending::Handle in a thread-local and runs the
|
|
30
|
+
# caller's enqueue inside it; #initialize below captures it onto the job
|
|
31
|
+
# instance, #serialize copies it into the job's metadata, #deserialize
|
|
32
|
+
# restores it. So `perform`'s ARITY IS UNTOUCHED and every OTHER caller of
|
|
33
|
+
# the same job — a nightly sweep, a webhook — builds it with no handle, in
|
|
34
|
+
# which case `reactive_settle` is a NO-OP that returns nil. That is
|
|
35
|
+
# load-bearing: these jobs almost always have non-UI callers.
|
|
36
|
+
#
|
|
37
|
+
# (A `perform_now` INSIDE the enqueue block does carry the handle, since the
|
|
38
|
+
# instance is built there — so it settles synchronously. That is the right
|
|
39
|
+
# reading: `reply.pending` already marked the targets, and something has to
|
|
40
|
+
# resolve the shimmer. The settle's durable message is replayed from
|
|
41
|
+
# since-id 0 when the client opens the subscription, so arriving before it
|
|
42
|
+
# exists is safe.)
|
|
43
|
+
#
|
|
44
|
+
# ## A rolled-back action
|
|
45
|
+
#
|
|
46
|
+
# The endpoint builds the pending markers and the subscription directive only
|
|
47
|
+
# AFTER the action's transaction committed, so a rolled-back action leaks
|
|
48
|
+
# neither. Whether the ENQUEUE survives the rollback is the queue adapter's
|
|
49
|
+
# business, exactly as it is for a bare `perform_later` in an action — and if
|
|
50
|
+
# such a job does run, its settle broadcasts to a key nobody ever subscribed
|
|
51
|
+
# to. That is inert: the one-shot queue is reclaimed by pgbus's orphan sweep.
|
|
52
|
+
#
|
|
53
|
+
# ## Failure is never silent, and never permanent
|
|
54
|
+
#
|
|
55
|
+
# If the block raises, the target's pending markers are still cleared (the
|
|
56
|
+
# shimmer must not lie) and the error is RE-RAISED so the retry policy sees
|
|
57
|
+
# it. The shared subscription is deliberately NOT torn down on failure — a
|
|
58
|
+
# retry must still be able to reach the actor.
|
|
59
|
+
module Settles
|
|
60
|
+
# ActiveJob metadata key. Prefixed and spelled out — job metadata is a
|
|
61
|
+
# shared namespace with every other gem in the app.
|
|
62
|
+
SETTLE_METADATA_KEY = "phlex_reactive_settle"
|
|
63
|
+
|
|
64
|
+
# Capture the in-flight settle handle at INSTANTIATION (issue #254).
|
|
65
|
+
#
|
|
66
|
+
# `serialize` is the seam pgbus's own ActiveJob::CurrentAttributes
|
|
67
|
+
# integration uses, and it looked like the enqueue-time hook — but under
|
|
68
|
+
# Rails' `enqueue_after_transaction_commit = true` (the 7.2+ recommended
|
|
69
|
+
# setting) `job.enqueue` is deferred to
|
|
70
|
+
# ActiveRecord.after_all_transactions_commit, and the endpoint runs every
|
|
71
|
+
# action inside a transaction. So the deferral — and with it `serialize` —
|
|
72
|
+
# always fires AFTER reply.pending's `with_handle` block has exited, with
|
|
73
|
+
# an empty thread-local: no metadata key, a no-op `reactive_settle`, and a
|
|
74
|
+
# row left shimmering until someone reloads.
|
|
75
|
+
#
|
|
76
|
+
# `new` is the one moment guaranteed to be inside the block:
|
|
77
|
+
# `perform_later` → `job_or_instantiate` → `new` is synchronous, deferral
|
|
78
|
+
# or not. Pending#each_with_narrowed_handle narrows the thread-local per
|
|
79
|
+
# record BEFORE `perform_later`, so the `job:`/`args:` attribution
|
|
80
|
+
# contract is preserved.
|
|
81
|
+
def initialize(...)
|
|
82
|
+
super
|
|
83
|
+
@reactive_settle_handle ||= Phlex::Reactive::Pending.current_handle
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# The same capture at ENQUEUE, for an instance built BEFORE the block and
|
|
87
|
+
# enqueued inside it (`job = MyJob.new(...)` … `reply.pending { job.enqueue }`).
|
|
88
|
+
# `enqueue` runs synchronously inside the block — it is the deferral it
|
|
89
|
+
# REGISTERS that runs later — so this is the last moment the thread-local
|
|
90
|
+
# is visible. `||=` never overwrites: a retry's `retry_job` re-enqueues a
|
|
91
|
+
# DESERIALIZED instance, which must keep the handle it came back with.
|
|
92
|
+
def enqueue(...)
|
|
93
|
+
@reactive_settle_handle ||= Phlex::Reactive::Pending.current_handle
|
|
94
|
+
super
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Prefer the captured handle; the thread-local fallback covers an instance
|
|
98
|
+
# serialized inside the block without going through either hook. A retry
|
|
99
|
+
# re-enqueue re-serializes the SAME instance, which keeps its handle — the
|
|
100
|
+
# right reading: the pending UI is still waiting on this work.
|
|
101
|
+
def serialize
|
|
102
|
+
handle = @reactive_settle_handle || Phlex::Reactive::Pending.current_handle
|
|
103
|
+
return super unless handle
|
|
104
|
+
|
|
105
|
+
super.merge(SETTLE_METADATA_KEY => handle.to_h_wire)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def deserialize(job_data)
|
|
109
|
+
super
|
|
110
|
+
@reactive_settle_handle = Phlex::Reactive::Pending::Handle.from_wire(job_data[SETTLE_METADATA_KEY])
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Settle the UI this job's work was enqueued for. Yields a
|
|
114
|
+
# Phlex::Reactive::Settle bound to the container, rebuilt from the signed
|
|
115
|
+
# identity the enqueue captured. Returns nil (and never runs the block)
|
|
116
|
+
# when this job carries no handle.
|
|
117
|
+
#
|
|
118
|
+
# `finish:` controls teardown of the SHARED one-shot subscription. All N
|
|
119
|
+
# settles of one reply.pending share ONE stream key (a key per record
|
|
120
|
+
# would mean a PGMQ table and an SSE connection per record), so tearing
|
|
121
|
+
# down on the first arrival would cut off the other N-1. The default
|
|
122
|
+
# (:auto) therefore finishes only when reply.pending marked exactly one
|
|
123
|
+
# target; a fan-out passes `finish: true` from whatever knows it is last —
|
|
124
|
+
# a Pgbus::Batch on_finish callback, or the final job of a staggered
|
|
125
|
+
# sequence. Until then the subscription is superseded by the container's
|
|
126
|
+
# next pending call, or closed when the page unloads.
|
|
127
|
+
def reactive_settle(finish: :auto)
|
|
128
|
+
handle = @reactive_settle_handle
|
|
129
|
+
return nil unless handle
|
|
130
|
+
|
|
131
|
+
settle = build_settle(handle)
|
|
132
|
+
yield settle
|
|
133
|
+
deliver_settle(handle, settle, finish)
|
|
134
|
+
settle
|
|
135
|
+
rescue ::StandardError
|
|
136
|
+
# The shimmer must resolve even when the work blew up. Clear the pending
|
|
137
|
+
# markers, keep the subscription (a retry still needs it), then re-raise
|
|
138
|
+
# so ActiveJob's retry policy gets its chance. A failure to broadcast the
|
|
139
|
+
# cleanup itself propagates too — there is nothing deliverable, and the
|
|
140
|
+
# next attempt is the only remaining hope.
|
|
141
|
+
broadcast_settle_cleanup(@reactive_settle_handle) if @reactive_settle_handle
|
|
142
|
+
raise
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
private
|
|
146
|
+
|
|
147
|
+
def build_settle(handle)
|
|
148
|
+
container = handle.container_class.constantize.from_identity(handle.container_payload)
|
|
149
|
+
Phlex::Reactive::Settle.new(container, handle)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# ONE durable message to the actor, carrying every stream this settle
|
|
153
|
+
# produced. Bundling is deliberate: the row, the count companion and the
|
|
154
|
+
# empty-state toggle belong to the same instant, and one message per settle
|
|
155
|
+
# is also why the ACTOR path needs no coalescing — there is nothing extra
|
|
156
|
+
# to collapse. (Coalescing applies to the PEERS path, where the aggregates
|
|
157
|
+
# really are separate channel calls.)
|
|
158
|
+
def deliver_settle(handle, settle, finish)
|
|
159
|
+
payload = settle.streams.join
|
|
160
|
+
payload += finish_streams(handle) if finish_settle?(handle, finish)
|
|
161
|
+
broadcast_settle_payload(handle, payload) unless payload.empty?
|
|
162
|
+
deliver_peers(handle, settle)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def finish_settle?(handle, finish)
|
|
166
|
+
return finish unless finish == :auto
|
|
167
|
+
|
|
168
|
+
handle.count.to_i <= 1
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# The teardown: clear the pending markers from EVERY target this handle
|
|
172
|
+
# owns as well as the container, then remove the client's
|
|
173
|
+
# <pgbus-stream-source> by its deterministic id — its disconnectedCallback
|
|
174
|
+
# closes the SSE, so the subscription tears itself down with the content it
|
|
175
|
+
# delivered.
|
|
176
|
+
#
|
|
177
|
+
# Clearing the targets (not just the anchor) is load-bearing: a settle that
|
|
178
|
+
# only flashes — "could not re-execute", say — emits no row stream at all,
|
|
179
|
+
# so nothing swaps that row's node and its markers would otherwise sit
|
|
180
|
+
# there forever. Clearing an id whose node WAS replaced or removed is a
|
|
181
|
+
# harmless no-op (the client op resolves to nothing).
|
|
182
|
+
def finish_streams(handle)
|
|
183
|
+
clear_pending_streams(handle.target_ids + [handle.anchor]) + source_teardown(handle.anchor)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# One reactive:js clear per id, concatenated. html_safe by construction —
|
|
187
|
+
# each piece is a SafeBuffer from js_stream.
|
|
188
|
+
def clear_pending_streams(ids)
|
|
189
|
+
ids.uniq.map { clear_pending_js(it).to_s }.join.html_safe
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def source_teardown(anchor)
|
|
193
|
+
target = Phlex::Reactive::Pending.source_id(anchor)
|
|
194
|
+
%(<turbo-stream action="remove" target="#{ERB::Util.html_escape(target)}"></turbo-stream>).html_safe
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# reactive:js ops that strip the pending vocabulary from ONE element.
|
|
198
|
+
def clear_pending_js(target)
|
|
199
|
+
ops = Phlex::Reactive::JS.new
|
|
200
|
+
.remove_attr(:root, Phlex::Reactive::Pending::PENDING_ATTR)
|
|
201
|
+
.remove_attr(:root, "aria-busy")
|
|
202
|
+
.remove_attr(:root, "data-reactive-defer-pending")
|
|
203
|
+
Phlex::Reactive::Response.js_stream(ops, target:)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# The failure path: clear the pending state so the UI stops lying, WITHOUT
|
|
207
|
+
# tearing the subscription down (a retry must still be able to reach the
|
|
208
|
+
# actor).
|
|
209
|
+
#
|
|
210
|
+
# ATTRIBUTION is the constraint. A handle that owns exactly ONE target —
|
|
211
|
+
# which is every job the `job:`/`args:` sugar enqueues, since it narrows
|
|
212
|
+
# the handle per record — unambiguously identifies the row that just
|
|
213
|
+
# failed, so both it and the container are cleared. A handle that owns
|
|
214
|
+
# MANY (the block form, where the gem cannot map an arbitrary enqueue back
|
|
215
|
+
# to a record) cannot: clearing all of them would un-dim 176 rows that are
|
|
216
|
+
# still legitimately working, and clearing the container alone would claim
|
|
217
|
+
# the whole batch is done. So that case clears nothing and says so — the
|
|
218
|
+
# fan-out's own `finish: true` is what sweeps it up.
|
|
219
|
+
def broadcast_settle_cleanup(handle)
|
|
220
|
+
unless handle.target_ids.one?
|
|
221
|
+
warn_unattributable_failure(handle)
|
|
222
|
+
return
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
broadcast_settle_payload(handle, clear_pending_streams(handle.target_ids + [handle.anchor]))
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def warn_unattributable_failure(handle)
|
|
229
|
+
return unless defined?(::Rails) && ::Rails.respond_to?(:logger) && ::Rails.logger
|
|
230
|
+
|
|
231
|
+
::Rails.logger.warn(
|
|
232
|
+
"[phlex-reactive] a settle failed for a #{handle.count}-target reply.pending — the gem " \
|
|
233
|
+
"cannot tell WHICH target this job owned (the enqueue used the block form), so no " \
|
|
234
|
+
"pending marker was cleared. Those markers clear when a settle calls " \
|
|
235
|
+
"reactive_settle(finish: true)."
|
|
236
|
+
)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Durable is load-bearing: pgbus's since-id replay only covers
|
|
240
|
+
# PGMQ-persisted messages, and that replay is what closes the
|
|
241
|
+
# broadcast-before-subscribe race (the actor may still be opening the SSE
|
|
242
|
+
# when a fast job finishes).
|
|
243
|
+
def broadcast_settle_payload(handle, payload)
|
|
244
|
+
::Pgbus.stream(handle.stream_key, durable: true).broadcast(payload)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# The peers leg (issue #248): the same collection deltas, broadcast to the
|
|
248
|
+
# container's stream so a second operator watching the same batch sees
|
|
249
|
+
# them. Sent AFTER the actor's message — the actor paid for the click and
|
|
250
|
+
# should not wait behind a fan-out of channel calls. `exclude:` is the
|
|
251
|
+
# actor's connection id, so they never get the delta twice.
|
|
252
|
+
# Peer delivery is BEST EFFORT and never fails the job. The actor's durable
|
|
253
|
+
# message has already been sent by the time we get here; re-raising would
|
|
254
|
+
# hand the job to the retry policy, and the retry would re-run `perform`
|
|
255
|
+
# and send the ACTOR's settle a second time — duplicating the pieces that
|
|
256
|
+
# are not idempotent (a flash, an empty-state append). A peer who missed a
|
|
257
|
+
# cross-tab courtesy is a far smaller problem than an actor who sees the
|
|
258
|
+
# flash twice, and every other broadcast in the gem is best-effort too.
|
|
259
|
+
def deliver_peers(handle, settle)
|
|
260
|
+
return if handle.peers.nil? || settle.peer_ops.empty?
|
|
261
|
+
|
|
262
|
+
keys = handle.peers.map { it.is_a?(Hash) ? GlobalID::Locator.locate(it["gid"]) : it }
|
|
263
|
+
container = settle.container
|
|
264
|
+
|
|
265
|
+
settle.peer_ops.each { deliver_peer_op(container, keys, handle, it) }
|
|
266
|
+
rescue ::StandardError => e
|
|
267
|
+
log_peer_failure(e)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# ONE peer op. A collection delta (append/prepend/remove) goes through
|
|
271
|
+
# broadcast_collection_to so peers get the count companion and the
|
|
272
|
+
# empty-state toggle too; a REPLACE moves no boundary, so it rides the
|
|
273
|
+
# ordinary row broadcast. A nil name means the settle handed us a built
|
|
274
|
+
# component, which self-targets.
|
|
275
|
+
def deliver_peer_op(container, keys, handle, peer_op)
|
|
276
|
+
name, action, model, row_kwargs = peer_op
|
|
277
|
+
return broadcast_peer_component(keys, handle, model) if name.nil?
|
|
278
|
+
|
|
279
|
+
if action == :replace
|
|
280
|
+
definition = Phlex::Reactive::Collections.definition!(container, name)
|
|
281
|
+
return definition.item.broadcast_to(
|
|
282
|
+
*keys, replace: definition.item.send(:build, model, row_kwargs || {}),
|
|
283
|
+
exclude: handle.connection_id
|
|
284
|
+
)
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
container.class.broadcast_collection_to(
|
|
288
|
+
*keys, container:, in: name, action => model, row: row_kwargs || {},
|
|
289
|
+
exclude: handle.connection_id,
|
|
290
|
+
coalesce: Phlex::Reactive.settle_coalesce_window_ms
|
|
291
|
+
)
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def broadcast_peer_component(keys, handle, component)
|
|
295
|
+
component.class.broadcast_to(*keys, replace: component, exclude: handle.connection_id)
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def log_peer_failure(error)
|
|
299
|
+
return unless defined?(::Rails) && ::Rails.respond_to?(:logger) && ::Rails.logger
|
|
300
|
+
|
|
301
|
+
::Rails.logger.warn(
|
|
302
|
+
"[phlex-reactive] a settle's PEER broadcast failed (#{error.class}: #{error.message}) — " \
|
|
303
|
+
"the actor's settle already landed, so the job is NOT failed: retrying it would deliver " \
|
|
304
|
+
"the actor's settle (and its flash) a second time."
|
|
305
|
+
)
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
end
|