upkeep-rails 0.1.9 → 0.1.12
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/README.md +105 -195
- data/docs/drafts/turbo-streams-is-cache-invalidation-you-write-by-hand.md +240 -0
- data/docs/handoffs/_archive/2026-06-10-main.md +47 -0
- data/docs/how-it-works.md +8 -0
- data/lib/generators/upkeep/install/install_generator.rb +59 -0
- data/lib/generators/upkeep/install/templates/subscription.js +6 -5
- data/lib/generators/upkeep/install/templates/upkeep.rb +8 -7
- data/lib/upkeep/delivery/turbo_streams.rb +40 -15
- data/lib/upkeep/dependencies.rb +55 -5
- data/lib/upkeep/invalidation/planner.rb +48 -10
- data/lib/upkeep/rails/cable/channel.rb +27 -5
- data/lib/upkeep/rails/cable/subscriber_identity.rb +21 -1
- data/lib/upkeep/rails/client_subscription.rb +12 -12
- data/lib/upkeep/rails/cluster_guard.rb +57 -0
- data/lib/upkeep/rails/configuration.rb +9 -16
- data/lib/upkeep/rails/controller_runtime.rb +17 -0
- data/lib/upkeep/rails/railtie.rb +1 -10
- data/lib/upkeep/rails/testing.rb +1 -1
- data/lib/upkeep/rails.rb +58 -17
- data/lib/upkeep/runtime.rb +39 -2
- data/lib/upkeep/shared_streams.rb +17 -3
- data/lib/upkeep/subscriptions/active_record_store.rb +53 -62
- data/lib/upkeep/subscriptions/active_record_subscription_persistence.rb +6 -3
- data/lib/upkeep/subscriptions/active_registry.rb +0 -7
- data/lib/upkeep/subscriptions/base_store.rb +106 -0
- data/lib/upkeep/subscriptions/layered_reverse_index.rb +9 -13
- data/lib/upkeep/subscriptions/lookup_instrumentation.rb +32 -0
- data/lib/upkeep/subscriptions/persistent_reverse_index.rb +4 -1
- data/lib/upkeep/subscriptions/store.rb +38 -64
- data/lib/upkeep/version.rb +1 -1
- data/upkeep-rails.gemspec +0 -1
- metadata +7 -24
- data/lib/upkeep/rails/delivery_job.rb +0 -29
- data/lib/upkeep/subscriptions/async_durable_writer.rb +0 -131
data/lib/upkeep/dependencies.rb
CHANGED
|
@@ -54,10 +54,13 @@ module Upkeep
|
|
|
54
54
|
end
|
|
55
55
|
|
|
56
56
|
class ActiveRecordAttribute < Base
|
|
57
|
-
def initialize(table:, id:, attribute:, model: nil)
|
|
57
|
+
def initialize(table:, id:, attribute:, model: nil, scope: nil)
|
|
58
|
+
scope = normalize_scope(scope)
|
|
59
|
+
key = { table: table, id: id, attribute: attribute }
|
|
60
|
+
key[:scope] = scope if scope
|
|
58
61
|
super(
|
|
59
62
|
source: :active_record_attribute,
|
|
60
|
-
key:
|
|
63
|
+
key: key,
|
|
61
64
|
metadata: { model: model }.compact
|
|
62
65
|
)
|
|
63
66
|
end
|
|
@@ -65,7 +68,8 @@ module Upkeep
|
|
|
65
68
|
def matches_change?(change)
|
|
66
69
|
key.fetch(:table) == change.fetch(:table) &&
|
|
67
70
|
(key.fetch(:id).nil? || !change[:id] || key.fetch(:id) == change[:id]) &&
|
|
68
|
-
change.fetch(:changed_attributes, []).include?(key.fetch(:attribute))
|
|
71
|
+
change.fetch(:changed_attributes, []).include?(key.fetch(:attribute)) &&
|
|
72
|
+
scope_matches_change?(change)
|
|
69
73
|
end
|
|
70
74
|
|
|
71
75
|
def precision
|
|
@@ -75,6 +79,37 @@ module Upkeep
|
|
|
75
79
|
def narrow_frame_safe?
|
|
76
80
|
true
|
|
77
81
|
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def normalize_scope(scope)
|
|
86
|
+
return nil unless scope.is_a?(Hash)
|
|
87
|
+
|
|
88
|
+
normalized = scope.each_with_object({}) do |(column, value), result|
|
|
89
|
+
result[column.to_s] = value unless value.nil?
|
|
90
|
+
end
|
|
91
|
+
normalized.empty? ? nil : normalized
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def scope_matches_change?(change)
|
|
95
|
+
scope = key[:scope]
|
|
96
|
+
return true unless scope
|
|
97
|
+
|
|
98
|
+
scope.all? do |column, value|
|
|
99
|
+
observed = observed_scope_values(change, column)
|
|
100
|
+
observed.empty? || observed.any? { |observed_value| scope_values_equal?(observed_value, value) }
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def observed_scope_values(change, column)
|
|
105
|
+
[change[:new_values], change[:old_values]].compact.filter_map do |values|
|
|
106
|
+
values[column] || values[column.to_sym]
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def scope_values_equal?(observed, expected)
|
|
111
|
+
observed == expected || observed.to_s == expected.to_s
|
|
112
|
+
end
|
|
78
113
|
end
|
|
79
114
|
|
|
80
115
|
class ActiveRecordCollection < Base
|
|
@@ -363,12 +398,19 @@ module Upkeep
|
|
|
363
398
|
end
|
|
364
399
|
|
|
365
400
|
class RequestValue < Identity
|
|
401
|
+
# Request values that are stable for a deployment/connection rather than a viewer.
|
|
402
|
+
# They never partition subscribers, but their fingerprints are folded into shared
|
|
403
|
+
# stream names (see SharedStreams.deployment_signature_for) so viewers with
|
|
404
|
+
# different values can never share a stream.
|
|
405
|
+
DEPLOYMENT_STABLE_KEYS = %w[host port protocol ssl request_method].freeze
|
|
406
|
+
|
|
366
407
|
def initialize(key:, value:)
|
|
367
408
|
super(
|
|
368
409
|
source: :request,
|
|
369
410
|
key: key.to_s,
|
|
370
411
|
value: Dependencies.private_fingerprint(value),
|
|
371
|
-
metadata: { key: key.to_s, value_class: value.class.name }
|
|
412
|
+
metadata: { key: key.to_s, value_class: value.class.name },
|
|
413
|
+
partitioning: DEPLOYMENT_STABLE_KEYS.include?(key.to_s) ? false : nil
|
|
372
414
|
)
|
|
373
415
|
end
|
|
374
416
|
end
|
|
@@ -421,7 +463,8 @@ module Upkeep
|
|
|
421
463
|
table: key.fetch(:table),
|
|
422
464
|
id: key.fetch(:id),
|
|
423
465
|
attribute: key.fetch(:attribute),
|
|
424
|
-
model: metadata[:model]
|
|
466
|
+
model: metadata[:model],
|
|
467
|
+
scope: key[:scope]
|
|
425
468
|
)
|
|
426
469
|
when :active_record_collection, :active_record_query
|
|
427
470
|
dependency_class = source.to_sym == :active_record_query ? ActiveRecordQuery : ActiveRecordCollection
|
|
@@ -488,6 +531,13 @@ module Upkeep
|
|
|
488
531
|
!nil_identity?(dependency)
|
|
489
532
|
end
|
|
490
533
|
|
|
534
|
+
def deployment_stable_request?(dependency)
|
|
535
|
+
return false unless dependency.identity?
|
|
536
|
+
return false unless dependency.source.to_s == "request"
|
|
537
|
+
|
|
538
|
+
RequestValue::DEPLOYMENT_STABLE_KEYS.include?(dependency.key.fetch(:key).to_s)
|
|
539
|
+
end
|
|
540
|
+
|
|
491
541
|
def identity_absent_for?(dependency, name)
|
|
492
542
|
absent_by_name = metadata_value(dependency, :identity_absent_by_name) || {}
|
|
493
543
|
absent_by_name = absent_by_name.transform_keys(&:to_s) if absent_by_name.respond_to?(:transform_keys)
|
|
@@ -14,6 +14,7 @@ module Upkeep
|
|
|
14
14
|
:frame_id,
|
|
15
15
|
:identity_signature,
|
|
16
16
|
:sharing_signature,
|
|
17
|
+
:deployment_signature,
|
|
17
18
|
:recipe,
|
|
18
19
|
:matched_dependency_keys,
|
|
19
20
|
:action,
|
|
@@ -32,7 +33,7 @@ module Upkeep
|
|
|
32
33
|
end
|
|
33
34
|
end
|
|
34
35
|
|
|
35
|
-
Plan = Data.define(:targets, :candidate_entries, :matched_entries) do
|
|
36
|
+
Plan = Data.define(:targets, :candidate_entries, :matched_entries, :request_id) do
|
|
36
37
|
def summary
|
|
37
38
|
{
|
|
38
39
|
targets: targets.size,
|
|
@@ -74,7 +75,7 @@ module Upkeep
|
|
|
74
75
|
)
|
|
75
76
|
end
|
|
76
77
|
|
|
77
|
-
plan = Plan.new(deduplicate_targets(targets), candidate_entries, matched_entries)
|
|
78
|
+
plan = Plan.new(deduplicate_targets(targets), candidate_entries, matched_entries, request_id_for(changes))
|
|
78
79
|
payload.merge!(payload_for(plan))
|
|
79
80
|
plan
|
|
80
81
|
end
|
|
@@ -86,6 +87,12 @@ module Upkeep
|
|
|
86
87
|
|
|
87
88
|
attr_reader :store
|
|
88
89
|
|
|
90
|
+
# All changes in a set were captured during one request, so the first stamped
|
|
91
|
+
# request id speaks for the whole plan. Writes from jobs/console carry none.
|
|
92
|
+
def request_id_for(changes)
|
|
93
|
+
changes.filter_map { |change| change[:request_id] if change.respond_to?(:[]) }.first
|
|
94
|
+
end
|
|
95
|
+
|
|
89
96
|
def payload_for(plan)
|
|
90
97
|
{
|
|
91
98
|
candidate_entries: plan.candidate_entries.size,
|
|
@@ -152,7 +159,8 @@ module Upkeep
|
|
|
152
159
|
shared_stream_target = target
|
|
153
160
|
identity_signature = subscription.identity_signature(frame_id)
|
|
154
161
|
sharing_signature = SharedStreams.signature_for(recipe) if shared_delivery && identity_signature == "public" && frame.payload.fetch(:kind) == "render_site"
|
|
155
|
-
|
|
162
|
+
deployment_signature = SharedStreams.deployment_signature_for(subscription.graph, frame.id) if sharing_signature
|
|
163
|
+
action, recipe, delivery_target, deoptimization_reason = cached_delivery_strategy(subscription.graph, frame, recipe, entries, changes, sharing_signature: sharing_signature)
|
|
156
164
|
target = delivery_target || target
|
|
157
165
|
subscriber_ids = represented_subscriber_ids(subscription, entries)
|
|
158
166
|
|
|
@@ -165,6 +173,7 @@ module Upkeep
|
|
|
165
173
|
frame_id,
|
|
166
174
|
identity_signature,
|
|
167
175
|
sharing_signature,
|
|
176
|
+
deployment_signature,
|
|
168
177
|
recipe,
|
|
169
178
|
dependency_keys,
|
|
170
179
|
action,
|
|
@@ -179,12 +188,12 @@ module Upkeep
|
|
|
179
188
|
subscriber_ids
|
|
180
189
|
end
|
|
181
190
|
|
|
182
|
-
def cached_delivery_strategy(frame, recipe, entries, changes, sharing_signature:)
|
|
191
|
+
def cached_delivery_strategy(graph, frame, recipe, entries, changes, sharing_signature:)
|
|
183
192
|
key = delivery_strategy_cache_key(frame, recipe, entries, changes, sharing_signature)
|
|
184
|
-
return delivery_strategy(frame, recipe, entries, changes) unless key
|
|
193
|
+
return delivery_strategy(graph, frame, recipe, entries, changes) unless key
|
|
185
194
|
|
|
186
195
|
@delivery_strategy_cache.fetch(key) do
|
|
187
|
-
@delivery_strategy_cache[key] = delivery_strategy(frame, recipe, entries, changes)
|
|
196
|
+
@delivery_strategy_cache[key] = delivery_strategy(graph, frame, recipe, entries, changes)
|
|
188
197
|
end
|
|
189
198
|
end
|
|
190
199
|
|
|
@@ -211,7 +220,7 @@ module Upkeep
|
|
|
211
220
|
end
|
|
212
221
|
end
|
|
213
222
|
|
|
214
|
-
def delivery_strategy(frame, recipe, entries, changes)
|
|
223
|
+
def delivery_strategy(graph, frame, recipe, entries, changes)
|
|
215
224
|
remove_recipe = remove_recipe_for(frame, recipe, entries, changes)
|
|
216
225
|
if remove_recipe
|
|
217
226
|
return [
|
|
@@ -234,7 +243,7 @@ module Upkeep
|
|
|
234
243
|
return ["replace", member_replace_recipe, delivery_target, nil]
|
|
235
244
|
end
|
|
236
245
|
|
|
237
|
-
[fallback_action_for(frame), recipe, nil, deoptimization_reason(frame, entries, changes)]
|
|
246
|
+
[fallback_action_for(frame), recipe, nil, deoptimization_reason(graph, frame, entries, changes)]
|
|
238
247
|
end
|
|
239
248
|
|
|
240
249
|
def fallback_action_for(frame)
|
|
@@ -292,8 +301,16 @@ module Upkeep
|
|
|
292
301
|
CollectionRemove.build(recipe: recipe, change: destroy_changes.first)
|
|
293
302
|
end
|
|
294
303
|
|
|
295
|
-
def deoptimization_reason(frame, entries, changes)
|
|
296
|
-
|
|
304
|
+
def deoptimization_reason(graph, frame, entries, changes)
|
|
305
|
+
case frame.payload.fetch(:kind)
|
|
306
|
+
when "page"
|
|
307
|
+
page_frame_deoptimization_reason(graph, frame, entries)
|
|
308
|
+
when "render_site"
|
|
309
|
+
render_site_deoptimization_reason(entries, changes)
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def render_site_deoptimization_reason(entries, changes)
|
|
297
314
|
return unless entries.any? { |entry| entry.dependency.source == :active_record_collection }
|
|
298
315
|
|
|
299
316
|
if changes.one? { |change| change[:id] && change.fetch(:type).to_s.include?("create") }
|
|
@@ -307,6 +324,25 @@ module Upkeep
|
|
|
307
324
|
end
|
|
308
325
|
end
|
|
309
326
|
|
|
327
|
+
def page_frame_deoptimization_reason(graph, frame, entries)
|
|
328
|
+
return "no_render_site" unless contains_render_site?(graph, frame)
|
|
329
|
+
|
|
330
|
+
"page_frame_dependency:#{entries.map { |entry| dependency_source_label(entry.dependency) }.min}"
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def contains_render_site?(graph, frame)
|
|
334
|
+
graph.contained_node_ids(frame.id).any? do |node_id|
|
|
335
|
+
node = graph.node(node_id)
|
|
336
|
+
node.kind == :frame && node.payload[:kind] == "render_site" && node.payload[:recipe]
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def dependency_source_label(dependency)
|
|
341
|
+
return dependency.source.to_s unless dependency.identity?
|
|
342
|
+
|
|
343
|
+
"#{dependency.source}:#{dependency.key.fetch(:key)}"
|
|
344
|
+
end
|
|
345
|
+
|
|
310
346
|
def destroy_change?(change)
|
|
311
347
|
type = change.fetch(:type).to_s
|
|
312
348
|
type.include?("destroy") || type.include?("delete")
|
|
@@ -332,6 +368,7 @@ module Upkeep
|
|
|
332
368
|
target.target.id,
|
|
333
369
|
target.identity_signature,
|
|
334
370
|
target.sharing_signature,
|
|
371
|
+
target.deployment_signature,
|
|
335
372
|
target.action,
|
|
336
373
|
target.deoptimization_reason
|
|
337
374
|
]
|
|
@@ -349,6 +386,7 @@ module Upkeep
|
|
|
349
386
|
existing.frame_id,
|
|
350
387
|
existing.identity_signature,
|
|
351
388
|
existing.sharing_signature,
|
|
389
|
+
existing.deployment_signature,
|
|
352
390
|
existing.recipe,
|
|
353
391
|
(existing.matched_dependency_keys + target.matched_dependency_keys).uniq,
|
|
354
392
|
existing.action,
|
|
@@ -9,6 +9,17 @@ module Upkeep
|
|
|
9
9
|
class Channel < ::ActionCable::Channel::Base
|
|
10
10
|
SUBSCRIBE_NOTIFICATION = "subscribe_channel.upkeep"
|
|
11
11
|
|
|
12
|
+
# Liveness heartbeat: a connected page touches its subscription row on
|
|
13
|
+
# this interval, keeping updated_at fresh so opportunistic pruning only
|
|
14
|
+
# ever removes abandoned subscriptions. Invariant: connected =>
|
|
15
|
+
# touched at least every HEARTBEAT_INTERVAL => retained, so this must
|
|
16
|
+
# stay far below config.subscription_ttl (20 minutes vs a 24 hour
|
|
17
|
+
# default). It is a constant because ActionCable fixes periodic timer
|
|
18
|
+
# intervals at class load, before app configuration is readable.
|
|
19
|
+
HEARTBEAT_INTERVAL = 20 * 60
|
|
20
|
+
|
|
21
|
+
periodically :touch_upkeep_subscription, every: HEARTBEAT_INTERVAL
|
|
22
|
+
|
|
12
23
|
def subscribed
|
|
13
24
|
if ActiveSupport::Notifications.notifier.listening?(SUBSCRIBE_NOTIFICATION)
|
|
14
25
|
instrumented_subscribe
|
|
@@ -25,6 +36,16 @@ module Upkeep
|
|
|
25
36
|
|
|
26
37
|
private
|
|
27
38
|
|
|
39
|
+
# Cheap liveness touch (update_columns on the subscription row). Must
|
|
40
|
+
# never raise into the cable process; a missed heartbeat just leaves
|
|
41
|
+
# the subscription for a later beat or the opportunistic trim.
|
|
42
|
+
def touch_upkeep_subscription
|
|
43
|
+
Upkeep::Rails.subscriptions.touch(subscription_id)
|
|
44
|
+
nil
|
|
45
|
+
rescue StandardError
|
|
46
|
+
nil
|
|
47
|
+
end
|
|
48
|
+
|
|
28
49
|
def instrumented_subscribe
|
|
29
50
|
payload = { subscription_id: safe_subscription_id }
|
|
30
51
|
ActiveSupport::Notifications.instrument(SUBSCRIBE_NOTIFICATION, payload) do
|
|
@@ -106,13 +127,14 @@ module Upkeep
|
|
|
106
127
|
end
|
|
107
128
|
|
|
108
129
|
def log_subscription_rejection(reason)
|
|
109
|
-
return unless reason == "missing_activation_token"
|
|
110
130
|
return unless defined?(::Rails) && ::Rails.respond_to?(:logger) && ::Rails.logger
|
|
111
131
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
"If this started after upgrading upkeep-rails,
|
|
115
|
-
|
|
132
|
+
message = +"[upkeep] subscription rejected (#{reason}) subscription_id=#{safe_subscription_id.inspect}."
|
|
133
|
+
if reason == "missing_activation_token"
|
|
134
|
+
message << " If this started after upgrading upkeep-rails, " \
|
|
135
|
+
"refresh app/javascript/upkeep/subscription.js from the install generator."
|
|
136
|
+
end
|
|
137
|
+
::Rails.logger.warn(message)
|
|
116
138
|
end
|
|
117
139
|
|
|
118
140
|
def authorized_subscription?(subscription)
|
|
@@ -41,8 +41,28 @@ module Upkeep
|
|
|
41
41
|
|
|
42
42
|
attr_reader :action_cable_connection
|
|
43
43
|
|
|
44
|
+
# ActionCable::Connection::Base keeps `request` private on Rails
|
|
45
|
+
# 7.1-8.x but exposes `env` as a public attr_reader; the adapterized
|
|
46
|
+
# connection on Rails main exposes both publicly. Prefer a public
|
|
47
|
+
# `request`, otherwise build one from the public Rack env the same
|
|
48
|
+
# way Action Cable does. Never reach into private connection API.
|
|
44
49
|
def action_cable_request
|
|
45
|
-
|
|
50
|
+
@action_cable_request ||=
|
|
51
|
+
if action_cable_connection.respond_to?(:request)
|
|
52
|
+
action_cable_connection.request
|
|
53
|
+
elsif action_cable_connection.respond_to?(:env)
|
|
54
|
+
::ActionDispatch::Request.new(rails_rack_env)
|
|
55
|
+
else
|
|
56
|
+
raise UnidentifiedSubscriber,
|
|
57
|
+
"ActionCable connection exposes neither a public request nor a public env"
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def rails_rack_env
|
|
62
|
+
env = action_cable_connection.env
|
|
63
|
+
return env unless defined?(::Rails.application) && ::Rails.application
|
|
64
|
+
|
|
65
|
+
::Rails.application.env_config.merge(env)
|
|
46
66
|
end
|
|
47
67
|
end
|
|
48
68
|
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "cgi"
|
|
4
|
-
require "json"
|
|
5
4
|
|
|
6
5
|
module Upkeep
|
|
7
6
|
module Rails
|
|
@@ -16,20 +15,21 @@ module Upkeep
|
|
|
16
15
|
"#{html}#{marker}"
|
|
17
16
|
end
|
|
18
17
|
|
|
18
|
+
# The payload travels as attributes (like turbo-cable-stream-source), never
|
|
19
|
+
# as text content, so it can't show up as page text when JS is absent.
|
|
19
20
|
def marker_for(identity:, subscription:)
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
id = "upkeep-subscription-source-#{subscription.id}"
|
|
21
|
+
attributes = {
|
|
22
|
+
"id" => "upkeep-subscription-source-#{subscription.id}",
|
|
23
|
+
"channel" => CHANNEL,
|
|
24
|
+
"subscription-id" => subscription.id,
|
|
25
|
+
"activation-token" => ActivationToken.generate(subscription),
|
|
26
|
+
"stream-name" => identity.stream_name
|
|
27
|
+
}
|
|
28
28
|
|
|
29
29
|
[
|
|
30
|
-
%(<upkeep-subscription-source
|
|
31
|
-
%(
|
|
32
|
-
|
|
30
|
+
%(<upkeep-subscription-source ),
|
|
31
|
+
attributes.map { |name, value| %(#{name}="#{CGI.escapeHTML(value.to_s)}") }.join(" "),
|
|
32
|
+
%( hidden style="display:none" data-upkeep-subscription data-turbo-temporary>),
|
|
33
33
|
%(</upkeep-subscription-source>)
|
|
34
34
|
].join
|
|
35
35
|
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Upkeep
|
|
4
|
+
module Rails
|
|
5
|
+
# Detects boot configurations where live updates silently break across
|
|
6
|
+
# cluster workers: an in-process cable adapter or an in-memory
|
|
7
|
+
# subscription store cannot reach browsers or subscriptions held by
|
|
8
|
+
# another process.
|
|
9
|
+
class ClusterGuard
|
|
10
|
+
IN_PROCESS_CABLE_ADAPTERS = %w[async].freeze
|
|
11
|
+
|
|
12
|
+
attr_reader :cable_adapter, :worker_count, :subscription_store, :environment
|
|
13
|
+
|
|
14
|
+
def initialize(cable_adapter:, worker_count:, subscription_store:, environment:)
|
|
15
|
+
@cable_adapter = cable_adapter.to_s
|
|
16
|
+
@worker_count = worker_count.to_i
|
|
17
|
+
@subscription_store = subscription_store&.to_sym
|
|
18
|
+
@environment = environment.to_s
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def clustered?
|
|
22
|
+
worker_count.positive?
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def problems
|
|
26
|
+
return [] unless clustered?
|
|
27
|
+
|
|
28
|
+
problems = []
|
|
29
|
+
if IN_PROCESS_CABLE_ADAPTERS.include?(cable_adapter)
|
|
30
|
+
problems << "the #{cable_adapter} Action Cable adapter is in-process, so broadcasts from one worker " \
|
|
31
|
+
"never reach sockets held by another; configure a cross-process cable adapter such as solid_cable " \
|
|
32
|
+
"or redis in config/cable.yml"
|
|
33
|
+
end
|
|
34
|
+
if subscription_store == :memory
|
|
35
|
+
problems << "subscription_store=:memory is per-process, so subscriptions registered in one worker are " \
|
|
36
|
+
"invisible to the others; set config.upkeep.subscription_store = :active_record"
|
|
37
|
+
end
|
|
38
|
+
problems
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def error?
|
|
42
|
+
problems.any? && environment == "production"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def warning?
|
|
46
|
+
problems.any? && !error?
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def message
|
|
50
|
+
return if problems.empty?
|
|
51
|
+
|
|
52
|
+
"Upkeep detected a clustered server (#{worker_count} workers) with a configuration that cannot " \
|
|
53
|
+
"deliver live updates across processes: #{problems.join("; ")}."
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -8,7 +8,6 @@ module Upkeep
|
|
|
8
8
|
SUBSCRIPTION_STORES = [:active_record, :memory].freeze
|
|
9
9
|
REFUSED_BOUNDARY_BEHAVIORS = [:raise, :warn].freeze
|
|
10
10
|
IDENTITY_SOURCES = [:current, :session, :cookie, :warden].freeze
|
|
11
|
-
DELIVERY_ADAPTERS = [:async, :active_job, :inline].freeze
|
|
12
11
|
|
|
13
12
|
class IdentityDefinition
|
|
14
13
|
attr_reader :name, :source, :source_key, :subscribe_block
|
|
@@ -99,18 +98,23 @@ module Upkeep
|
|
|
99
98
|
attr_accessor :enabled
|
|
100
99
|
attr_accessor :activation_token_expires_in
|
|
101
100
|
attr_accessor :delivery_batch_window
|
|
102
|
-
|
|
101
|
+
# Seconds a subscription may go untouched before it counts as abandoned
|
|
102
|
+
# and becomes eligible for pruning. Connected pages are kept alive by the
|
|
103
|
+
# cable channel heartbeat, which fires far more often than this TTL.
|
|
104
|
+
attr_accessor :subscription_ttl
|
|
105
|
+
# Test/console hook: deliver changes synchronously in the caller instead
|
|
106
|
+
# of on the in-process background dispatcher.
|
|
107
|
+
attr_accessor :deliver_inline
|
|
103
108
|
attr_reader :subscription_store
|
|
104
|
-
attr_reader :delivery_adapter
|
|
105
109
|
|
|
106
110
|
def initialize
|
|
107
111
|
@enabled = true
|
|
108
112
|
@subscription_store = :active_record
|
|
109
|
-
@
|
|
110
|
-
@delivery_queue = :upkeep_realtime
|
|
113
|
+
@deliver_inline = false
|
|
111
114
|
@delivery_batch_window = 0.01
|
|
112
115
|
@refused_boundary_behavior = nil
|
|
113
116
|
@activation_token_expires_in = 24 * 60 * 60
|
|
117
|
+
@subscription_ttl = 24 * 60 * 60
|
|
114
118
|
@identity_definitions = {}
|
|
115
119
|
end
|
|
116
120
|
|
|
@@ -125,17 +129,6 @@ module Upkeep
|
|
|
125
129
|
@subscription_store = value
|
|
126
130
|
end
|
|
127
131
|
|
|
128
|
-
def delivery_adapter=(value)
|
|
129
|
-
value = value.to_sym if value.respond_to?(:to_sym)
|
|
130
|
-
|
|
131
|
-
unless DELIVERY_ADAPTERS.include?(value)
|
|
132
|
-
raise ConfigurationError,
|
|
133
|
-
"Unknown Upkeep delivery_adapter #{value.inspect}; expected one of #{DELIVERY_ADAPTERS.join(", ")}"
|
|
134
|
-
end
|
|
135
|
-
|
|
136
|
-
@delivery_adapter = value
|
|
137
|
-
end
|
|
138
|
-
|
|
139
132
|
def refused_boundary_behavior
|
|
140
133
|
@refused_boundary_behavior || default_refused_boundary_behavior
|
|
141
134
|
end
|
|
@@ -87,6 +87,7 @@ module Upkeep
|
|
|
87
87
|
end
|
|
88
88
|
end
|
|
89
89
|
end
|
|
90
|
+
changes = upkeep_stamp_change_request_id(changes)
|
|
90
91
|
record_capture_payload(payload, capture) if capture
|
|
91
92
|
|
|
92
93
|
measure_phase(payload, :deliver_changes_ms) do
|
|
@@ -118,6 +119,22 @@ module Upkeep
|
|
|
118
119
|
result
|
|
119
120
|
end
|
|
120
121
|
|
|
122
|
+
# Changes committed while handling this request carry the originating Turbo
|
|
123
|
+
# request id, so the client that caused them ignores its own refresh delivery
|
|
124
|
+
# (Turbo's recentRequests debounce). Breaks self-refresh loops from writes
|
|
125
|
+
# during GETs, e.g. view tracking.
|
|
126
|
+
def upkeep_stamp_change_request_id(changes)
|
|
127
|
+
request_id = upkeep_turbo_request_id
|
|
128
|
+
return changes unless request_id
|
|
129
|
+
|
|
130
|
+
changes.map { |change| change.respond_to?(:merge) ? change.merge(request_id: request_id) : change }
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def upkeep_turbo_request_id
|
|
134
|
+
turbo_request_id = ::Turbo.current_request_id if defined?(::Turbo) && ::Turbo.respond_to?(:current_request_id)
|
|
135
|
+
turbo_request_id || request.headers["X-Turbo-Request-Id"]
|
|
136
|
+
end
|
|
137
|
+
|
|
121
138
|
def record_capture_payload(payload, capture)
|
|
122
139
|
payload[:response_status] = capture.response_status
|
|
123
140
|
payload[:response_content_type] = capture.response_content_type
|
data/lib/upkeep/rails/railtie.rb
CHANGED
|
@@ -9,8 +9,7 @@ module Upkeep
|
|
|
9
9
|
Upkeep::Rails.configure do |config|
|
|
10
10
|
config.enabled = app.config.upkeep.fetch(:enabled, true)
|
|
11
11
|
config.subscription_store = app.config.upkeep.fetch(:subscription_store, config.subscription_store)
|
|
12
|
-
config.
|
|
13
|
-
config.delivery_queue = app.config.upkeep.fetch(:delivery_queue, config.delivery_queue)
|
|
12
|
+
config.deliver_inline = app.config.upkeep.fetch(:deliver_inline, config.deliver_inline)
|
|
14
13
|
config.delivery_batch_window =
|
|
15
14
|
app.config.upkeep.fetch(:delivery_batch_window, config.delivery_batch_window)
|
|
16
15
|
config.activation_token_expires_in =
|
|
@@ -37,14 +36,6 @@ module Upkeep
|
|
|
37
36
|
::Rake.respond_to?(:application) &&
|
|
38
37
|
::Rake.application.top_level_tasks.any?
|
|
39
38
|
end
|
|
40
|
-
|
|
41
|
-
def self.default_delivery_adapter(app)
|
|
42
|
-
if app.respond_to?(:env) && app.env.to_s == "production"
|
|
43
|
-
:active_job
|
|
44
|
-
else
|
|
45
|
-
Upkeep::Rails.configuration.delivery_adapter
|
|
46
|
-
end
|
|
47
|
-
end
|
|
48
39
|
end
|
|
49
40
|
end
|
|
50
41
|
end
|
data/lib/upkeep/rails/testing.rb
CHANGED