upkeep-rails 0.1.9
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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +311 -0
- data/docs/how-it-works.md +269 -0
- data/lib/generators/upkeep/install/install_generator.rb +127 -0
- data/lib/generators/upkeep/install/templates/create_upkeep_subscriptions.rb.erb +49 -0
- data/lib/generators/upkeep/install/templates/subscription.js +99 -0
- data/lib/generators/upkeep/install/templates/upkeep.rb +63 -0
- data/lib/upkeep/active_record_query.rb +392 -0
- data/lib/upkeep/capture/request.rb +150 -0
- data/lib/upkeep/dag/subscription_shape.rb +244 -0
- data/lib/upkeep/dag.rb +370 -0
- data/lib/upkeep/delivery/action_cable_adapter.rb +43 -0
- data/lib/upkeep/delivery/async_dispatcher.rb +102 -0
- data/lib/upkeep/delivery/broadcast_transport.rb +89 -0
- data/lib/upkeep/delivery/transport.rb +194 -0
- data/lib/upkeep/delivery/turbo_streams.rb +302 -0
- data/lib/upkeep/delivery.rb +7 -0
- data/lib/upkeep/dependencies.rb +550 -0
- data/lib/upkeep/herb/developer_report.rb +135 -0
- data/lib/upkeep/herb/manifest_cache.rb +83 -0
- data/lib/upkeep/herb/manifest_diff.rb +183 -0
- data/lib/upkeep/herb/source_instrumenter.rb +149 -0
- data/lib/upkeep/herb/template_manifest.rb +518 -0
- data/lib/upkeep/invalidation/collection_append.rb +84 -0
- data/lib/upkeep/invalidation/collection_member_replace.rb +78 -0
- data/lib/upkeep/invalidation/collection_prepend.rb +84 -0
- data/lib/upkeep/invalidation/collection_remove.rb +57 -0
- data/lib/upkeep/invalidation/planner.rb +360 -0
- data/lib/upkeep/invalidation.rb +7 -0
- data/lib/upkeep/rails/action_view_capture.rb +920 -0
- data/lib/upkeep/rails/activation_token.rb +55 -0
- data/lib/upkeep/rails/cable/channel.rb +143 -0
- data/lib/upkeep/rails/cable/subscriber_identity.rb +341 -0
- data/lib/upkeep/rails/cable.rb +4 -0
- data/lib/upkeep/rails/client_subscription.rb +45 -0
- data/lib/upkeep/rails/configuration.rb +245 -0
- data/lib/upkeep/rails/controller_runtime.rb +154 -0
- data/lib/upkeep/rails/delivery_job.rb +29 -0
- data/lib/upkeep/rails/install.rb +28 -0
- data/lib/upkeep/rails/railtie.rb +50 -0
- data/lib/upkeep/rails/replay.rb +197 -0
- data/lib/upkeep/rails/testing.rb +258 -0
- data/lib/upkeep/rails.rb +370 -0
- data/lib/upkeep/replay.rb +439 -0
- data/lib/upkeep/runtime.rb +1202 -0
- data/lib/upkeep/shared_streams.rb +72 -0
- data/lib/upkeep/subscriptions/active_record_store.rb +387 -0
- data/lib/upkeep/subscriptions/active_record_subscription_persistence.rb +407 -0
- data/lib/upkeep/subscriptions/active_registry.rb +87 -0
- data/lib/upkeep/subscriptions/async_durable_writer.rb +131 -0
- data/lib/upkeep/subscriptions/json_snapshot.rb +98 -0
- data/lib/upkeep/subscriptions/layered_reverse_index.rb +129 -0
- data/lib/upkeep/subscriptions/persistent_reverse_index.rb +223 -0
- data/lib/upkeep/subscriptions/registrar.rb +36 -0
- data/lib/upkeep/subscriptions/reverse_index.rb +301 -0
- data/lib/upkeep/subscriptions/shape.rb +116 -0
- data/lib/upkeep/subscriptions/store.rb +375 -0
- data/lib/upkeep/subscriptions.rb +7 -0
- data/lib/upkeep/targeting.rb +135 -0
- data/lib/upkeep/version.rb +5 -0
- data/lib/upkeep-rails.rb +3 -0
- data/lib/upkeep.rb +14 -0
- data/upkeep-rails.gemspec +54 -0
- metadata +308 -0
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Upkeep
|
|
4
|
+
module Rails
|
|
5
|
+
# Test helpers for asserting the public Upkeep subscription lifecycle from
|
|
6
|
+
# Rails request, integration, and system tests.
|
|
7
|
+
module Testing
|
|
8
|
+
CHANGE_FACTS_THREAD_KEY = :upkeep_rails_testing_change_facts
|
|
9
|
+
@broadcast_capture_mutex = Mutex.new
|
|
10
|
+
@broadcast_captures = []
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
# Drains the async delivery dispatcher when a test needs deterministic
|
|
14
|
+
# broadcast assertions.
|
|
15
|
+
#
|
|
16
|
+
# Production code should not call this; normal app delivery runs
|
|
17
|
+
# through the configured adapter.
|
|
18
|
+
#
|
|
19
|
+
# @return [void]
|
|
20
|
+
def drain_delivery!
|
|
21
|
+
Upkeep::Rails.send(:drain_delivery_dispatcher!)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Captures facts passed into Upkeep delivery while the block runs. This
|
|
25
|
+
# exposes the same committed-change payloads the planner sees, without
|
|
26
|
+
# broadcasting or altering application code.
|
|
27
|
+
#
|
|
28
|
+
# @return [Array(Object, Array<Hash>)] the block result and captured facts.
|
|
29
|
+
def capture_change_facts
|
|
30
|
+
previous = Thread.current[CHANGE_FACTS_THREAD_KEY]
|
|
31
|
+
facts = []
|
|
32
|
+
Thread.current[CHANGE_FACTS_THREAD_KEY] = facts
|
|
33
|
+
|
|
34
|
+
[yield, facts]
|
|
35
|
+
ensure
|
|
36
|
+
Thread.current[CHANGE_FACTS_THREAD_KEY] = previous
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Records delivery facts for capture_change_facts. Internal test hook;
|
|
40
|
+
# production delivery calls this only when a capture is active.
|
|
41
|
+
def record_change_facts(changes)
|
|
42
|
+
facts = Thread.current[CHANGE_FACTS_THREAD_KEY]
|
|
43
|
+
return unless facts
|
|
44
|
+
|
|
45
|
+
facts.concat(Array(changes).map { |change| clone_change_fact(change) })
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @return [Boolean] true when the current thread is capturing change facts.
|
|
49
|
+
def capturing_change_facts?
|
|
50
|
+
!!Thread.current[CHANGE_FACTS_THREAD_KEY]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Captures rendered Upkeep delivery payloads while the block runs.
|
|
54
|
+
# This observes the batch after planning/rendering and before the
|
|
55
|
+
# app-specific transport adapter, so tests stay deterministic across
|
|
56
|
+
# ActionCable adapters.
|
|
57
|
+
def capture_broadcasts
|
|
58
|
+
broadcasts = []
|
|
59
|
+
broadcast_capture_mutex.synchronize { broadcast_captures << broadcasts }
|
|
60
|
+
|
|
61
|
+
yield
|
|
62
|
+
drain_delivery!
|
|
63
|
+
broadcasts.dup
|
|
64
|
+
ensure
|
|
65
|
+
broadcast_capture_mutex.synchronize { broadcast_captures.delete(broadcasts) } if broadcasts
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# @return [Boolean] true when any thread is capturing delivery batches.
|
|
69
|
+
def capturing_broadcasts?
|
|
70
|
+
broadcast_capture_mutex.synchronize { broadcast_captures.any? }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Records a rendered delivery batch for active capture_broadcasts
|
|
74
|
+
# blocks. Internal test hook; production delivery calls this only when
|
|
75
|
+
# a capture is active.
|
|
76
|
+
def record_delivery_batch(batch)
|
|
77
|
+
captures = broadcast_capture_mutex.synchronize { broadcast_captures.dup }
|
|
78
|
+
return if captures.empty?
|
|
79
|
+
|
|
80
|
+
bodies = batch.envelopes.map(&:body)
|
|
81
|
+
return if bodies.empty?
|
|
82
|
+
|
|
83
|
+
broadcast_capture_mutex.synchronize do
|
|
84
|
+
captures.each do |capture|
|
|
85
|
+
capture.concat(bodies) if broadcast_captures.include?(capture)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Runs the invalidation planner against committed-change facts without
|
|
91
|
+
# enqueueing delivery or broadcasting.
|
|
92
|
+
#
|
|
93
|
+
# @param changes [Hash, Array<Hash>] one or more change facts.
|
|
94
|
+
# @param store [#reverse_index] subscription store to inspect.
|
|
95
|
+
# @return [Hash] concise planner match report.
|
|
96
|
+
def match_report(changes, store: Upkeep::Rails.subscriptions)
|
|
97
|
+
changes = changes.is_a?(Hash) ? [changes] : Array(changes)
|
|
98
|
+
lookup_payloads = []
|
|
99
|
+
subscription = ActiveSupport::Notifications.subscribe("lookup_subscription_index.upkeep") do |event|
|
|
100
|
+
lookup_payloads << event.payload.dup
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
plan = Upkeep::Invalidation::Planner.new(store: store).plan(changes)
|
|
104
|
+
|
|
105
|
+
{
|
|
106
|
+
candidate_entries: plan.candidate_entries.size,
|
|
107
|
+
matched_entries: plan.matched_entries.size,
|
|
108
|
+
miss_reason: match_miss_reason(plan, changes, lookup_payloads),
|
|
109
|
+
targets: plan.targets.map { |target| match_report_target(target) }
|
|
110
|
+
}
|
|
111
|
+
ensure
|
|
112
|
+
ActiveSupport::Notifications.unsubscribe(subscription) if subscription
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
attr_reader :broadcast_capture_mutex, :broadcast_captures
|
|
118
|
+
|
|
119
|
+
def clone_change_fact(change)
|
|
120
|
+
case change
|
|
121
|
+
when Hash
|
|
122
|
+
change.to_h.transform_values { |value| clone_change_fact(value) }
|
|
123
|
+
when Array
|
|
124
|
+
change.map { |value| clone_change_fact(value) }
|
|
125
|
+
else
|
|
126
|
+
begin
|
|
127
|
+
change.dup
|
|
128
|
+
rescue TypeError
|
|
129
|
+
change
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def match_miss_reason(plan, changes, lookup_payloads)
|
|
135
|
+
return nil if plan.targets.any?
|
|
136
|
+
return "no_changes" if changes.empty?
|
|
137
|
+
|
|
138
|
+
lookup_payloads.reverse_each do |payload|
|
|
139
|
+
return payload.fetch(:miss_reason) if payload.key?(:miss_reason)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
return "no_matching_subscriber" if plan.candidate_entries.empty?
|
|
143
|
+
return "dependencies_did_not_match_change" if plan.matched_entries.empty?
|
|
144
|
+
|
|
145
|
+
"no_renderable_target"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def match_report_target(target)
|
|
149
|
+
{
|
|
150
|
+
subscription_id: target.subscription_id,
|
|
151
|
+
subscriber_id: target.subscriber_id,
|
|
152
|
+
subscriber_ids: target.subscriber_ids,
|
|
153
|
+
target: {
|
|
154
|
+
kind: target.target.kind,
|
|
155
|
+
id: target.target.id,
|
|
156
|
+
reason: target.target.reason
|
|
157
|
+
},
|
|
158
|
+
shared_stream_target: {
|
|
159
|
+
kind: target.shared_stream_target.kind,
|
|
160
|
+
id: target.shared_stream_target.id
|
|
161
|
+
},
|
|
162
|
+
frame_id: target.frame_id,
|
|
163
|
+
identity_signature: target.identity_signature,
|
|
164
|
+
action: target.action,
|
|
165
|
+
matched_dependency_keys: target.matched_dependency_keys,
|
|
166
|
+
deoptimization_reason: target.deoptimization_reason
|
|
167
|
+
}
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Asserts that the last successful HTML response injected an Upkeep
|
|
172
|
+
# subscription marker and registered a subscription in the configured
|
|
173
|
+
# store.
|
|
174
|
+
#
|
|
175
|
+
# @param message [String, nil] optional assertion failure message.
|
|
176
|
+
# @return [void]
|
|
177
|
+
def assert_upkeep_subscription_registered(message = nil)
|
|
178
|
+
assert_select "upkeep-subscription-source[data-upkeep-subscription]"
|
|
179
|
+
assert Upkeep::Rails.subscriptions.subscriptions.any?,
|
|
180
|
+
message || "expected Upkeep to register at least one subscription"
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Returns the most recently registered Upkeep subscription.
|
|
184
|
+
#
|
|
185
|
+
# @return [Upkeep::Subscriptions::Subscription, nil]
|
|
186
|
+
def upkeep_subscription
|
|
187
|
+
Upkeep::Rails.subscriptions.subscriptions.last
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Returns every ActionCable stream name that can receive broadcasts for a
|
|
191
|
+
# subscription, including shared streams.
|
|
192
|
+
#
|
|
193
|
+
# @param subscription [Upkeep::Subscriptions::Subscription]
|
|
194
|
+
# @return [Array<String>]
|
|
195
|
+
# @raise [ArgumentError] when no subscription is registered.
|
|
196
|
+
def upkeep_stream_names(subscription = upkeep_subscription)
|
|
197
|
+
raise ArgumentError, "no Upkeep subscription is registered" unless subscription
|
|
198
|
+
|
|
199
|
+
([subscription.metadata.fetch(:stream_name)] + subscription.metadata.fetch(:shared_stream_names, [])).uniq
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Activates the registered subscription so delivery lookup can find it.
|
|
203
|
+
#
|
|
204
|
+
# @param subscription [Upkeep::Subscriptions::Subscription]
|
|
205
|
+
# @return [Upkeep::Subscriptions::Subscription]
|
|
206
|
+
# @raise [ArgumentError] when no subscription is registered.
|
|
207
|
+
# @raise [Upkeep::Subscriptions::NotFound] when activation fails.
|
|
208
|
+
def activate_upkeep_subscription!(subscription = upkeep_subscription)
|
|
209
|
+
raise ArgumentError, "no Upkeep subscription is registered" unless subscription
|
|
210
|
+
|
|
211
|
+
activated = Upkeep::Rails.subscriptions.activate(subscription.id)
|
|
212
|
+
raise Upkeep::Subscriptions::NotFound, subscription.id unless activated
|
|
213
|
+
|
|
214
|
+
subscription
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Captures rendered Upkeep broadcasts while the block runs. This observes
|
|
218
|
+
# Upkeep after planning/rendering and before the application ActionCable
|
|
219
|
+
# adapter, so tests stay deterministic regardless of the host app's cable
|
|
220
|
+
# adapter.
|
|
221
|
+
#
|
|
222
|
+
# @param subscription [Upkeep::Subscriptions::Subscription]
|
|
223
|
+
# @return [Array<String>]
|
|
224
|
+
# @raise [ArgumentError] when called without a block or subscription.
|
|
225
|
+
def capture_upkeep_broadcasts(subscription = upkeep_subscription, &block)
|
|
226
|
+
raise ArgumentError, "capture_upkeep_broadcasts requires a block" unless block
|
|
227
|
+
raise ArgumentError, "no Upkeep subscription is registered" unless subscription
|
|
228
|
+
|
|
229
|
+
Upkeep::Rails::Testing.capture_broadcasts(&block)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Drains async Upkeep delivery for deterministic test assertions.
|
|
233
|
+
#
|
|
234
|
+
# @return [void]
|
|
235
|
+
def drain_upkeep_delivery!
|
|
236
|
+
Upkeep::Rails::Testing.drain_delivery!
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Captures facts passed into Upkeep delivery while the block runs.
|
|
240
|
+
#
|
|
241
|
+
# @return [Array(Object, Array<Hash>)] the block result and captured facts.
|
|
242
|
+
def capture_upkeep_change_facts(&block)
|
|
243
|
+
raise ArgumentError, "capture_upkeep_change_facts requires a block" unless block
|
|
244
|
+
|
|
245
|
+
Upkeep::Rails::Testing.capture_change_facts(&block)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Returns a dry-run invalidation planner report for one or more change
|
|
249
|
+
# facts against the configured Upkeep subscription store.
|
|
250
|
+
#
|
|
251
|
+
# @param changes [Hash, Array<Hash>] one or more change facts.
|
|
252
|
+
# @return [Hash]
|
|
253
|
+
def upkeep_match_report(changes)
|
|
254
|
+
Upkeep::Rails::Testing.match_report(changes)
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|
data/lib/upkeep/rails.rb
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/notifications"
|
|
4
|
+
require_relative "capture/request"
|
|
5
|
+
require_relative "subscriptions/registrar"
|
|
6
|
+
require_relative "rails/configuration"
|
|
7
|
+
require_relative "rails/activation_token"
|
|
8
|
+
require_relative "rails/delivery_job"
|
|
9
|
+
require_relative "rails/replay"
|
|
10
|
+
require_relative "rails/action_view_capture"
|
|
11
|
+
require_relative "rails/cable"
|
|
12
|
+
require_relative "rails/client_subscription"
|
|
13
|
+
require_relative "rails/controller_runtime"
|
|
14
|
+
require_relative "rails/install"
|
|
15
|
+
require_relative "rails/testing"
|
|
16
|
+
require_relative "rails/railtie" if defined?(::Rails::Railtie)
|
|
17
|
+
|
|
18
|
+
module Upkeep
|
|
19
|
+
module Rails
|
|
20
|
+
SUBSCRIPTION_IDENTITY = "upkeep.subscription_identity"
|
|
21
|
+
REQUEST_CAPTURE = "request_capture.upkeep"
|
|
22
|
+
DELIVERY_ENQUEUE = "delivery_enqueue.upkeep"
|
|
23
|
+
DELIVERY_ENQUEUE_ERROR = "delivery_enqueue_error.upkeep"
|
|
24
|
+
INTERNAL_DELIVERY_TABLES = %w[
|
|
25
|
+
upkeep_subscriptions
|
|
26
|
+
upkeep_subscription_index_entries
|
|
27
|
+
upkeep_subscription_shape_index_entries
|
|
28
|
+
].freeze
|
|
29
|
+
|
|
30
|
+
class << self
|
|
31
|
+
def configuration
|
|
32
|
+
@configuration ||= Configuration.new
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def configure
|
|
36
|
+
yield configuration
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def subscriptions
|
|
40
|
+
discard_subscription_store! if @subscriptions && subscription_store_config_changed?
|
|
41
|
+
@subscriptions ||= build_subscription_store
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def transport
|
|
45
|
+
@transport ||= Delivery::BroadcastTransport.new
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def reset_runtime!
|
|
49
|
+
@delivery_dispatcher&.shutdown
|
|
50
|
+
@delivery_dispatcher = nil
|
|
51
|
+
@subscription_shape_cache&.reset
|
|
52
|
+
@subscription_registrar = nil
|
|
53
|
+
discard_subscription_store! if @subscriptions
|
|
54
|
+
@subscriptions = build_subscription_store
|
|
55
|
+
@subscriptions.reset
|
|
56
|
+
@transport = Delivery::BroadcastTransport.new
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def register_controller_subscription(controller, capture)
|
|
60
|
+
recorder = capture.recorder
|
|
61
|
+
return unless subscription_response?(controller, capture)
|
|
62
|
+
|
|
63
|
+
decision = Cable::SubscriberIdentity.decision_for(controller.request, recorder: recorder)
|
|
64
|
+
unless recorder.reactive?
|
|
65
|
+
instrument_subscription_identity(
|
|
66
|
+
decision,
|
|
67
|
+
registered: false,
|
|
68
|
+
deopt_reason: "refused_boundary",
|
|
69
|
+
refused_boundaries: recorder.refused_boundaries.map(&:reason)
|
|
70
|
+
)
|
|
71
|
+
return
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
identity = Cable::SubscriberIdentity.derive_from_request(
|
|
75
|
+
controller.request,
|
|
76
|
+
recorder: recorder,
|
|
77
|
+
decision: decision
|
|
78
|
+
)
|
|
79
|
+
registration = subscription_registrar.register(
|
|
80
|
+
identity: identity,
|
|
81
|
+
decision: decision,
|
|
82
|
+
recorder: recorder,
|
|
83
|
+
signature: capture.signature,
|
|
84
|
+
metadata: identity_metadata(decision).merge(
|
|
85
|
+
path: controller.request.fullpath,
|
|
86
|
+
stream_name: identity.stream_name
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
instrument_subscription_identity(decision, registered: true, subscription: registration.subscription)
|
|
90
|
+
|
|
91
|
+
registration
|
|
92
|
+
rescue Cable::UnidentifiedSubscriber => error
|
|
93
|
+
instrument_subscription_identity(
|
|
94
|
+
decision || Cable::SubscriberIdentity.decision_for(controller.request, recorder: recorder),
|
|
95
|
+
registered: false,
|
|
96
|
+
deopt_reason: "unidentified_identity",
|
|
97
|
+
error: error.message
|
|
98
|
+
)
|
|
99
|
+
nil
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Dispatches committed application changes through the configured delivery
|
|
103
|
+
# adapter.
|
|
104
|
+
#
|
|
105
|
+
# ControllerRuntime calls this automatically after non-GET actions. Apps
|
|
106
|
+
# usually should not call it from controllers or models.
|
|
107
|
+
#
|
|
108
|
+
# @param changes [Array<#to_h>] change events to deliver. Defaults to the
|
|
109
|
+
# current runtime change log.
|
|
110
|
+
# @return [Upkeep::Delivery::Transport::DispatchReport]
|
|
111
|
+
def deliver_changes!(changes = Runtime::ChangeLog.drain)
|
|
112
|
+
changes = deliverable_changes(changes)
|
|
113
|
+
record_testing_change_facts(changes)
|
|
114
|
+
return Delivery::Transport::DispatchReport.new([]) if changes.empty?
|
|
115
|
+
|
|
116
|
+
dispatch_changes(changes)
|
|
117
|
+
rescue StandardError => error
|
|
118
|
+
instrument_delivery_enqueue_error(changes, error)
|
|
119
|
+
Delivery::Transport::DispatchReport.new([])
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Delivers committed application changes immediately in the current
|
|
123
|
+
# process.
|
|
124
|
+
#
|
|
125
|
+
# This is used by the inline delivery adapter and Active Job worker. Tests
|
|
126
|
+
# that need deterministic async delivery should use
|
|
127
|
+
# Upkeep::Rails::Testing instead of calling this directly.
|
|
128
|
+
#
|
|
129
|
+
# @param changes [Array<#to_h>] change events to deliver. Defaults to the
|
|
130
|
+
# current runtime change log.
|
|
131
|
+
# @return [Upkeep::Delivery::TurboStreams::Batch, Upkeep::Delivery::Transport::DispatchReport]
|
|
132
|
+
def deliver_changes_now!(changes = Runtime::ChangeLog.drain)
|
|
133
|
+
changes = deliverable_changes(changes)
|
|
134
|
+
record_testing_change_facts(changes)
|
|
135
|
+
return Delivery::Transport::DispatchReport.new([]) if changes.empty?
|
|
136
|
+
|
|
137
|
+
batch = delivery_batch_for([changes])
|
|
138
|
+
deliver_batch(batch)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def validate_configuration!(environment: rails_environment)
|
|
142
|
+
return true unless configuration.enabled
|
|
143
|
+
|
|
144
|
+
validate_subscription_store!(environment: environment)
|
|
145
|
+
true
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
private
|
|
149
|
+
|
|
150
|
+
def delivery_dispatcher
|
|
151
|
+
@delivery_dispatcher ||= Delivery::AsyncDispatcher.new(batch_window: configuration.delivery_batch_window) do |change_sets|
|
|
152
|
+
batch = delivery_batch_for(change_sets)
|
|
153
|
+
deliver_batch(batch)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def drain_delivery_dispatcher!
|
|
158
|
+
@delivery_dispatcher&.drain
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def dispatch_changes(changes)
|
|
162
|
+
payload = {
|
|
163
|
+
adapter: configuration.delivery_adapter,
|
|
164
|
+
queue: configuration.delivery_queue,
|
|
165
|
+
change_count: changes.size
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
ActiveSupport::Notifications.instrument(DELIVERY_ENQUEUE, payload) do
|
|
169
|
+
case configuration.delivery_adapter
|
|
170
|
+
when :active_job
|
|
171
|
+
DeliveryJob.perform_later(changes)
|
|
172
|
+
Delivery::Transport::DispatchReport.new([])
|
|
173
|
+
when :async
|
|
174
|
+
delivery_dispatcher.enqueue(changes)
|
|
175
|
+
when :inline
|
|
176
|
+
deliver_changes_now!(changes)
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def instrument_delivery_enqueue_error(changes, error)
|
|
182
|
+
ActiveSupport::Notifications.instrument(
|
|
183
|
+
DELIVERY_ENQUEUE_ERROR,
|
|
184
|
+
adapter: configuration.delivery_adapter,
|
|
185
|
+
queue: configuration.delivery_queue,
|
|
186
|
+
change_count: changes.size,
|
|
187
|
+
error_class: error.class.name,
|
|
188
|
+
error_message: error.message
|
|
189
|
+
)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def subscription_registrar
|
|
193
|
+
@subscription_registrar ||= Subscriptions::Registrar.new(
|
|
194
|
+
store: subscriptions,
|
|
195
|
+
shape_cache: subscription_shape_cache
|
|
196
|
+
)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def subscription_shape_cache
|
|
200
|
+
@subscription_shape_cache ||= Subscriptions::ShapeCache.new
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def delivery_batch_for(change_sets)
|
|
204
|
+
change_sets = compact_change_sets(change_sets)
|
|
205
|
+
return Delivery::TurboStreams::Batch.new([]) if change_sets.empty?
|
|
206
|
+
|
|
207
|
+
planner = Invalidation::Planner.new(store: subscriptions)
|
|
208
|
+
plans = change_sets.map { |changes| planner.plan(changes) }
|
|
209
|
+
plans = plans.reject { |plan| plan.targets.empty? }
|
|
210
|
+
return Delivery::TurboStreams::Batch.new([]) if plans.empty?
|
|
211
|
+
|
|
212
|
+
Delivery::TurboStreams.new.build_many(plans)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def deliver_batch(batch)
|
|
216
|
+
record_testing_delivery_batch(batch)
|
|
217
|
+
transport.deliver(batch)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def compact_change_sets(change_sets)
|
|
221
|
+
change_sets
|
|
222
|
+
.map { |changes| deliverable_changes(changes) }
|
|
223
|
+
.reject(&:empty?)
|
|
224
|
+
.uniq { |changes| change_set_key(changes) }
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def deliverable_changes(changes)
|
|
228
|
+
Array(changes).reject { |change| internal_delivery_change?(change) }
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def record_testing_change_facts(changes)
|
|
232
|
+
return unless defined?(Testing) && Testing.respond_to?(:record_change_facts)
|
|
233
|
+
return if Testing.respond_to?(:capturing_change_facts?) && !Testing.capturing_change_facts?
|
|
234
|
+
|
|
235
|
+
Testing.record_change_facts(changes)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def record_testing_delivery_batch(batch)
|
|
239
|
+
return unless defined?(Testing) && Testing.respond_to?(:record_delivery_batch)
|
|
240
|
+
return if Testing.respond_to?(:capturing_broadcasts?) && !Testing.capturing_broadcasts?
|
|
241
|
+
|
|
242
|
+
Testing.record_delivery_batch(batch)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def internal_delivery_change?(change)
|
|
246
|
+
INTERNAL_DELIVERY_TABLES.include?(change_value(change, :table).to_s)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def change_set_key(changes)
|
|
250
|
+
changes.map { |change| change_key(change) }.sort
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def change_key(change)
|
|
254
|
+
[
|
|
255
|
+
change_value(change, :type).to_s,
|
|
256
|
+
change_value(change, :table).to_s,
|
|
257
|
+
change_value(change, :model).to_s,
|
|
258
|
+
change_value(change, :id).to_s,
|
|
259
|
+
Array(change_value(change, :changed_attributes)).map(&:to_s).sort,
|
|
260
|
+
change_value(change, :old_values).inspect,
|
|
261
|
+
change_value(change, :new_values).inspect
|
|
262
|
+
]
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def change_value(change, key)
|
|
266
|
+
return unless change.respond_to?(:[])
|
|
267
|
+
|
|
268
|
+
change[key] || change[key.to_s]
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def discard_subscription_store!
|
|
272
|
+
@subscriptions&.shutdown
|
|
273
|
+
@subscriptions = nil
|
|
274
|
+
@subscription_registrar = nil
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def identity_metadata(decision)
|
|
278
|
+
{
|
|
279
|
+
identity_mode: decision.mode,
|
|
280
|
+
anonymous: decision.anonymous,
|
|
281
|
+
anonymous_deopt_reason: decision.deopt_reason,
|
|
282
|
+
identity_sources: decision.identity_sources,
|
|
283
|
+
identity_names: decision.identity_names
|
|
284
|
+
}.compact
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def instrument_subscription_identity(decision, registered:, subscription: nil, deopt_reason: nil, **extra)
|
|
288
|
+
ActiveSupport::Notifications.instrument(
|
|
289
|
+
SUBSCRIPTION_IDENTITY,
|
|
290
|
+
{
|
|
291
|
+
registered: registered,
|
|
292
|
+
subscription_id: subscription&.id,
|
|
293
|
+
subscriber_id: subscription&.subscriber_id,
|
|
294
|
+
identity_mode: decision&.mode,
|
|
295
|
+
anonymous: decision&.anonymous,
|
|
296
|
+
anonymous_deopt_reason: deopt_reason || decision&.deopt_reason,
|
|
297
|
+
identity_sources: decision&.identity_sources,
|
|
298
|
+
identity_names: decision&.identity_names
|
|
299
|
+
}.merge(extra)
|
|
300
|
+
)
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def subscription_response?(controller, capture)
|
|
304
|
+
controller.request.get? &&
|
|
305
|
+
capture.successful? &&
|
|
306
|
+
capture.html_response? &&
|
|
307
|
+
capture.html.include?("</") &&
|
|
308
|
+
capture.recorder.graph.frame_nodes.any?
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def build_subscription_store
|
|
312
|
+
case configuration.subscription_store
|
|
313
|
+
when :active_record
|
|
314
|
+
schema_errors = Subscriptions::ActiveRecordStore.schema_errors(connect: true)
|
|
315
|
+
unless schema_errors.empty?
|
|
316
|
+
raise ConfigurationError,
|
|
317
|
+
active_record_subscription_store_error(schema_errors)
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
Subscriptions::ActiveRecordStore.new
|
|
321
|
+
when :memory
|
|
322
|
+
Subscriptions::Store.new
|
|
323
|
+
end.tap do
|
|
324
|
+
@subscription_store_name = configuration.subscription_store
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def validate_subscription_store!(environment:)
|
|
329
|
+
if production_environment?(environment) && configuration.subscription_store == :memory
|
|
330
|
+
raise ConfigurationError,
|
|
331
|
+
"Upkeep subscription_store=:memory is only for development/test; production requires :active_record."
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
return true unless production_environment?(environment)
|
|
335
|
+
return true unless configuration.subscription_store == :active_record
|
|
336
|
+
schema_errors = Subscriptions::ActiveRecordStore.schema_errors(connect: true)
|
|
337
|
+
return true if schema_errors.empty?
|
|
338
|
+
|
|
339
|
+
raise ConfigurationError, active_record_subscription_store_error(schema_errors, production: true)
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def active_record_subscription_store_error(schema_errors, production: false)
|
|
343
|
+
prefix = if production
|
|
344
|
+
"Upkeep production boot requires compatible upkeep_subscriptions, " \
|
|
345
|
+
"upkeep_subscription_index_entries, and upkeep_subscription_shape_index_entries tables " \
|
|
346
|
+
"for subscription_store=:active_record."
|
|
347
|
+
else
|
|
348
|
+
"Upkeep subscription_store=:active_record requires compatible upkeep_subscriptions, " \
|
|
349
|
+
"upkeep_subscription_index_entries, and upkeep_subscription_shape_index_entries tables."
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
"#{prefix} Schema errors: #{schema_errors.join("; ")}. Run bin/rails generate upkeep:install " \
|
|
353
|
+
"and bin/rails db:migrate, rebuild stale development/test databases, or set " \
|
|
354
|
+
"config.upkeep.subscription_store = :memory in development/test."
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
def production_environment?(environment)
|
|
358
|
+
environment.to_s == "production"
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def rails_environment
|
|
362
|
+
::Rails.env if defined?(::Rails) && ::Rails.respond_to?(:env)
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def subscription_store_config_changed?
|
|
366
|
+
@subscription_store_name != configuration.subscription_store
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
end
|
|
370
|
+
end
|