upkeep-rails 0.1.0
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.
Potentially problematic release.
This version of upkeep-rails might be problematic. Click here for more details.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +424 -0
- data/docs/architecture/ambient-inputs-roadmap.md +306 -0
- data/docs/architecture/herb-roadmap.md +324 -0
- data/docs/architecture/identity-and-sharing.md +187 -0
- data/docs/architecture/query-dependencies.md +230 -0
- data/docs/cost-model-roadmap.md +703 -0
- data/docs/guides/getting-started.md +282 -0
- data/docs/handoff-2026-05-15.md +230 -0
- data/docs/production_roadmap.md +372 -0
- data/docs/shared-warm-scale-roadmap.md +214 -0
- data/docs/single-subscriber-cold-roadmap.md +192 -0
- data/docs/testing.md +113 -0
- data/lib/generators/upkeep/install/install_generator.rb +90 -0
- data/lib/generators/upkeep/install/templates/create_upkeep_subscriptions.rb.erb +31 -0
- data/lib/generators/upkeep/install/templates/subscription.js +107 -0
- data/lib/generators/upkeep/install/templates/upkeep.rb +6 -0
- data/lib/upkeep/active_record_query.rb +294 -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 +275 -0
- data/lib/upkeep/delivery.rb +7 -0
- data/lib/upkeep/dependencies.rb +466 -0
- data/lib/upkeep/herb/developer_report.rb +116 -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 +84 -0
- data/lib/upkeep/herb/template_manifest.rb +377 -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 +341 -0
- data/lib/upkeep/invalidation.rb +7 -0
- data/lib/upkeep/rails/action_view_capture.rb +765 -0
- data/lib/upkeep/rails/cable/channel.rb +108 -0
- data/lib/upkeep/rails/cable/subscriber_identity.rb +214 -0
- data/lib/upkeep/rails/cable.rb +4 -0
- data/lib/upkeep/rails/client_subscription.rb +37 -0
- data/lib/upkeep/rails/configuration.rb +57 -0
- data/lib/upkeep/rails/controller_runtime.rb +137 -0
- data/lib/upkeep/rails/install.rb +28 -0
- data/lib/upkeep/rails/railtie.rb +36 -0
- data/lib/upkeep/rails/replay.rb +176 -0
- data/lib/upkeep/rails/testing.rb +36 -0
- data/lib/upkeep/rails.rb +276 -0
- data/lib/upkeep/replay.rb +408 -0
- data/lib/upkeep/runtime.rb +1075 -0
- data/lib/upkeep/shared_streams.rb +72 -0
- data/lib/upkeep/subscriptions/active_record_store.rb +292 -0
- data/lib/upkeep/subscriptions/active_record_subscription_persistence.rb +291 -0
- data/lib/upkeep/subscriptions/active_registry.rb +93 -0
- data/lib/upkeep/subscriptions/async_durable_writer.rb +136 -0
- data/lib/upkeep/subscriptions/json_snapshot.rb +98 -0
- data/lib/upkeep/subscriptions/layered_reverse_index.rb +122 -0
- data/lib/upkeep/subscriptions/persistent_reverse_index.rb +144 -0
- data/lib/upkeep/subscriptions/registrar.rb +36 -0
- data/lib/upkeep/subscriptions/reverse_index.rb +294 -0
- data/lib/upkeep/subscriptions/shape.rb +116 -0
- data/lib/upkeep/subscriptions/store.rb +159 -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 +53 -0
- metadata +296 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Upkeep
|
|
4
|
+
module Invalidation
|
|
5
|
+
class CollectionAppend
|
|
6
|
+
def self.build(recipe:, change:)
|
|
7
|
+
new(recipe, change).build
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def initialize(recipe, change)
|
|
11
|
+
@recipe = recipe
|
|
12
|
+
@change = change
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def build
|
|
16
|
+
replay = recipe.replay
|
|
17
|
+
return unless replay.is_a?(Replay::Collection)
|
|
18
|
+
return if replay.derived_partial?
|
|
19
|
+
return unless create_change?
|
|
20
|
+
|
|
21
|
+
collection = replay.collection
|
|
22
|
+
return unless collection.is_a?(Replay::ActiveRecordRelationValue)
|
|
23
|
+
return unless collection.primary_key
|
|
24
|
+
return unless collection.appendable? || unfilled_limit_window?(collection)
|
|
25
|
+
|
|
26
|
+
model = constantize(collection.model)
|
|
27
|
+
return unless change.fetch(:table) == model.table_name
|
|
28
|
+
|
|
29
|
+
record = model.find_by(id: change.fetch(:id))
|
|
30
|
+
return unless record && relation_appends_record?(model, collection, record)
|
|
31
|
+
|
|
32
|
+
Replay::Recipe.new(
|
|
33
|
+
kind: :render_site_append,
|
|
34
|
+
frame_id: recipe.frame_id,
|
|
35
|
+
target_kind: recipe.target_kind,
|
|
36
|
+
target_id: recipe.target_id,
|
|
37
|
+
template: recipe.template,
|
|
38
|
+
metadata: recipe.metadata,
|
|
39
|
+
runtime: "rails",
|
|
40
|
+
replay: Replay::CollectionMember.new(
|
|
41
|
+
controller_class: replay.controller_class,
|
|
42
|
+
partial: replay.partial,
|
|
43
|
+
record: Replay.active_record_value(record),
|
|
44
|
+
options: replay.options
|
|
45
|
+
)
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
attr_reader :recipe, :change
|
|
52
|
+
|
|
53
|
+
def create_change?
|
|
54
|
+
change[:id] && change.fetch(:type).to_s.include?("create")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def relation_appends_record?(model, collection, record)
|
|
58
|
+
primary_key = collection.primary_key
|
|
59
|
+
snapshot_ids = collection.member_ids.map(&:to_s)
|
|
60
|
+
candidate_ids = (snapshot_ids + [record.public_send(primary_key).to_s]).uniq
|
|
61
|
+
ordered_ids = model.find_by_sql(collection.sql).filter_map do |candidate|
|
|
62
|
+
candidate_id = candidate.public_send(primary_key).to_s
|
|
63
|
+
candidate_id if candidate_ids.include?(candidate_id)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
ordered_ids.last == record.public_send(primary_key).to_s &&
|
|
67
|
+
(snapshot_ids - ordered_ids).empty?
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def unfilled_limit_window?(collection)
|
|
71
|
+
return false unless collection.limit_value
|
|
72
|
+
|
|
73
|
+
limit = Integer(collection.limit_value)
|
|
74
|
+
limit.positive? && collection.member_ids.size < limit
|
|
75
|
+
rescue ArgumentError, TypeError
|
|
76
|
+
false
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def constantize(name)
|
|
80
|
+
name.to_s.split("::").reduce(Object) { |namespace, constant_name| namespace.const_get(constant_name) }
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Upkeep
|
|
4
|
+
module Invalidation
|
|
5
|
+
class CollectionMemberReplace
|
|
6
|
+
def self.build(recipe:, change:)
|
|
7
|
+
new(recipe, change).build
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def initialize(recipe, change)
|
|
11
|
+
@recipe = recipe
|
|
12
|
+
@change = change
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def build
|
|
16
|
+
replay = recipe.replay
|
|
17
|
+
return unless replay.is_a?(Replay::Collection)
|
|
18
|
+
return if replay.derived_partial?
|
|
19
|
+
return unless update_change?
|
|
20
|
+
|
|
21
|
+
collection = replay.collection
|
|
22
|
+
return unless collection.is_a?(Replay::ActiveRecordRelationValue)
|
|
23
|
+
return unless collection.primary_key
|
|
24
|
+
return unless collection.member_ids.map(&:to_s).include?(change.fetch(:id).to_s)
|
|
25
|
+
|
|
26
|
+
model = constantize(collection.model)
|
|
27
|
+
return unless change.fetch(:table) == model.table_name
|
|
28
|
+
|
|
29
|
+
record = model.find_by(id: change.fetch(:id))
|
|
30
|
+
return unless record && relation_keeps_member_order?(model, collection)
|
|
31
|
+
|
|
32
|
+
Replay::Recipe.new(
|
|
33
|
+
kind: :render_site_member_replace,
|
|
34
|
+
frame_id: recipe.frame_id,
|
|
35
|
+
target_kind: "dom_id",
|
|
36
|
+
target_id: dom_id(model, change.fetch(:id)),
|
|
37
|
+
template: recipe.template,
|
|
38
|
+
metadata: recipe.metadata,
|
|
39
|
+
runtime: "rails",
|
|
40
|
+
replay: Replay::CollectionMember.new(
|
|
41
|
+
controller_class: replay.controller_class,
|
|
42
|
+
partial: replay.partial,
|
|
43
|
+
record: Replay.active_record_value(record),
|
|
44
|
+
options: replay.options
|
|
45
|
+
)
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
attr_reader :recipe, :change
|
|
52
|
+
|
|
53
|
+
def update_change?
|
|
54
|
+
type = change.fetch(:type).to_s
|
|
55
|
+
change[:id] && !type.include?("create") && !type.include?("destroy") && !type.include?("delete")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def relation_keeps_member_order?(model, collection)
|
|
59
|
+
primary_key = collection.primary_key
|
|
60
|
+
snapshot_ids = collection.member_ids.map(&:to_s)
|
|
61
|
+
ordered_ids = model.find_by_sql(collection.sql).filter_map do |candidate|
|
|
62
|
+
candidate_id = candidate.public_send(primary_key).to_s
|
|
63
|
+
candidate_id if snapshot_ids.include?(candidate_id)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
ordered_ids == snapshot_ids
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def dom_id(model, id)
|
|
70
|
+
"#{model.model_name.param_key}_#{id}"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def constantize(name)
|
|
74
|
+
name.to_s.split("::").reduce(Object) { |namespace, constant_name| namespace.const_get(constant_name) }
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Upkeep
|
|
4
|
+
module Invalidation
|
|
5
|
+
class CollectionPrepend
|
|
6
|
+
def self.build(recipe:, change:)
|
|
7
|
+
new(recipe, change).build
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def initialize(recipe, change)
|
|
11
|
+
@recipe = recipe
|
|
12
|
+
@change = change
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def build
|
|
16
|
+
replay = recipe.replay
|
|
17
|
+
return unless replay.is_a?(Replay::Collection)
|
|
18
|
+
return if replay.derived_partial?
|
|
19
|
+
return unless create_change?
|
|
20
|
+
|
|
21
|
+
collection = replay.collection
|
|
22
|
+
return unless collection.is_a?(Replay::ActiveRecordRelationValue)
|
|
23
|
+
return unless collection.primary_key
|
|
24
|
+
return unless collection.appendable? || unfilled_limit_window?(collection)
|
|
25
|
+
|
|
26
|
+
model = constantize(collection.model)
|
|
27
|
+
return unless change.fetch(:table) == model.table_name
|
|
28
|
+
|
|
29
|
+
record = model.find_by(id: change.fetch(:id))
|
|
30
|
+
return unless record && relation_prepends_record?(model, collection, record)
|
|
31
|
+
|
|
32
|
+
Replay::Recipe.new(
|
|
33
|
+
kind: :render_site_prepend,
|
|
34
|
+
frame_id: recipe.frame_id,
|
|
35
|
+
target_kind: recipe.target_kind,
|
|
36
|
+
target_id: recipe.target_id,
|
|
37
|
+
template: recipe.template,
|
|
38
|
+
metadata: recipe.metadata,
|
|
39
|
+
runtime: "rails",
|
|
40
|
+
replay: Replay::CollectionMember.new(
|
|
41
|
+
controller_class: replay.controller_class,
|
|
42
|
+
partial: replay.partial,
|
|
43
|
+
record: Replay.active_record_value(record),
|
|
44
|
+
options: replay.options
|
|
45
|
+
)
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
attr_reader :recipe, :change
|
|
52
|
+
|
|
53
|
+
def create_change?
|
|
54
|
+
change[:id] && change.fetch(:type).to_s.include?("create")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def relation_prepends_record?(model, collection, record)
|
|
58
|
+
primary_key = collection.primary_key
|
|
59
|
+
snapshot_ids = collection.member_ids.map(&:to_s)
|
|
60
|
+
candidate_ids = (snapshot_ids + [record.public_send(primary_key).to_s]).uniq
|
|
61
|
+
ordered_ids = model.find_by_sql(collection.sql).filter_map do |candidate|
|
|
62
|
+
candidate_id = candidate.public_send(primary_key).to_s
|
|
63
|
+
candidate_id if candidate_ids.include?(candidate_id)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
ordered_ids.first == record.public_send(primary_key).to_s &&
|
|
67
|
+
(snapshot_ids - ordered_ids).empty?
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def unfilled_limit_window?(collection)
|
|
71
|
+
return false unless collection.limit_value
|
|
72
|
+
|
|
73
|
+
limit = Integer(collection.limit_value)
|
|
74
|
+
limit.positive? && collection.member_ids.size < limit
|
|
75
|
+
rescue ArgumentError, TypeError
|
|
76
|
+
false
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def constantize(name)
|
|
80
|
+
name.to_s.split("::").reduce(Object) { |namespace, constant_name| namespace.const_get(constant_name) }
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Upkeep
|
|
4
|
+
module Invalidation
|
|
5
|
+
class CollectionRemove
|
|
6
|
+
def self.build(recipe:, change:)
|
|
7
|
+
new(recipe, change).build
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def initialize(recipe, change)
|
|
11
|
+
@recipe = recipe
|
|
12
|
+
@change = change
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def build
|
|
16
|
+
replay = recipe.replay
|
|
17
|
+
return unless replay.is_a?(Replay::Collection)
|
|
18
|
+
return unless destroy_change?
|
|
19
|
+
|
|
20
|
+
collection = replay.collection
|
|
21
|
+
return unless collection.is_a?(Replay::ActiveRecordRelationValue)
|
|
22
|
+
return unless collection.member_ids.map(&:to_s).include?(change.fetch(:id).to_s)
|
|
23
|
+
|
|
24
|
+
model = constantize(collection.model)
|
|
25
|
+
return unless change.fetch(:table) == model.table_name
|
|
26
|
+
|
|
27
|
+
Replay::Recipe.new(
|
|
28
|
+
kind: :render_site_remove,
|
|
29
|
+
frame_id: recipe.frame_id,
|
|
30
|
+
target_kind: "dom_id",
|
|
31
|
+
target_id: dom_id(model, change.fetch(:id)),
|
|
32
|
+
template: recipe.template,
|
|
33
|
+
metadata: recipe.metadata,
|
|
34
|
+
runtime: "rails",
|
|
35
|
+
replay: Replay::Empty.new
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
attr_reader :recipe, :change
|
|
42
|
+
|
|
43
|
+
def destroy_change?
|
|
44
|
+
type = change.fetch(:type).to_s
|
|
45
|
+
change[:id] && (type.include?("destroy") || type.include?("delete"))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def dom_id(model, id)
|
|
49
|
+
"#{model.model_name.param_key}_#{id}"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def constantize(name)
|
|
53
|
+
name.to_s.split("::").reduce(Object) { |namespace, constant_name| namespace.const_get(constant_name) }
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/notifications"
|
|
4
|
+
|
|
5
|
+
module Upkeep
|
|
6
|
+
module Invalidation
|
|
7
|
+
class Planner
|
|
8
|
+
PlannedTarget = Data.define(
|
|
9
|
+
:subscription_id,
|
|
10
|
+
:subscriber_id,
|
|
11
|
+
:subscriber_ids,
|
|
12
|
+
:target,
|
|
13
|
+
:frame_id,
|
|
14
|
+
:identity_signature,
|
|
15
|
+
:sharing_signature,
|
|
16
|
+
:recipe,
|
|
17
|
+
:matched_dependency_keys,
|
|
18
|
+
:action,
|
|
19
|
+
:deoptimization_reason
|
|
20
|
+
) do
|
|
21
|
+
def represented_subscriber_count
|
|
22
|
+
subscriber_ids.size
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def render
|
|
26
|
+
recipe.render_target(target)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def manifest_replay?
|
|
30
|
+
recipe.manifest_target_render?(target)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
Plan = Data.define(:targets, :candidate_entries, :matched_entries) do
|
|
35
|
+
def summary
|
|
36
|
+
{
|
|
37
|
+
targets: targets.size,
|
|
38
|
+
represented_subscribers: targets.sum(&:represented_subscriber_count),
|
|
39
|
+
candidate_entries: candidate_entries.size,
|
|
40
|
+
matched_entries: matched_entries.size,
|
|
41
|
+
target_kinds: targets.map { |target| target.target.kind }.uniq.sort,
|
|
42
|
+
manifest_replay_targets: targets.count(&:manifest_replay?),
|
|
43
|
+
deoptimizations: targets.filter_map(&:deoptimization_reason).tally
|
|
44
|
+
}
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def initialize(store:)
|
|
49
|
+
@store = store
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def plan(changes)
|
|
53
|
+
changes = Array(changes)
|
|
54
|
+
payload = { change_count: changes.size }
|
|
55
|
+
ActiveSupport::Notifications.instrument("plan.upkeep", payload) do
|
|
56
|
+
@delivery_strategy_cache = {}
|
|
57
|
+
candidate_entries = store.reverse_index.entries_for(changes)
|
|
58
|
+
matched_entries = candidate_entries.select { |entry| changes.any? { |change| entry.dependency.matches_change?(change) } }
|
|
59
|
+
entries_by_subscription_id = matched_entries.group_by(&:subscription_id)
|
|
60
|
+
subscriptions_by_id = entries_by_subscription_id.keys.to_h do |subscription_id|
|
|
61
|
+
[subscription_id, store.fetch(subscription_id)]
|
|
62
|
+
end
|
|
63
|
+
represented_subscriber_ids = matched_entries.flat_map(&:represented_subscriber_ids).uniq
|
|
64
|
+
represented_subscriber_ids = subscriptions_by_id.values.map(&:subscriber_id).uniq if represented_subscriber_ids.empty?
|
|
65
|
+
shared_delivery = represented_subscriber_ids.size > 1
|
|
66
|
+
|
|
67
|
+
targets = entries_by_subscription_id.flat_map do |subscription_id, entries|
|
|
68
|
+
targets_for_subscription(
|
|
69
|
+
subscriptions_by_id.fetch(subscription_id),
|
|
70
|
+
entries,
|
|
71
|
+
changes,
|
|
72
|
+
shared_delivery: shared_delivery
|
|
73
|
+
)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
plan = Plan.new(deduplicate_targets(targets), candidate_entries, matched_entries)
|
|
77
|
+
payload.merge!(payload_for(plan))
|
|
78
|
+
plan
|
|
79
|
+
end
|
|
80
|
+
ensure
|
|
81
|
+
@delivery_strategy_cache = nil
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
attr_reader :store
|
|
87
|
+
|
|
88
|
+
def payload_for(plan)
|
|
89
|
+
{
|
|
90
|
+
candidate_entries: plan.candidate_entries.size,
|
|
91
|
+
matched_entries: plan.matched_entries.size,
|
|
92
|
+
targets: plan.targets.size,
|
|
93
|
+
represented_subscribers: plan.targets.sum(&:represented_subscriber_count),
|
|
94
|
+
target_kinds: plan.targets.map { |target| target.target.kind }.uniq.sort,
|
|
95
|
+
manifest_replay_targets: plan.targets.count(&:manifest_replay?),
|
|
96
|
+
actions: plan.targets.map(&:action).tally,
|
|
97
|
+
deoptimizations: plan.targets.filter_map(&:deoptimization_reason).tally
|
|
98
|
+
}
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def targets_for_subscription(subscription, entries, changes, shared_delivery:)
|
|
102
|
+
frames_by_id = Hash.new { |hash, key| hash[key] = { node: nil, dependency_keys: [], entries: [] } }
|
|
103
|
+
|
|
104
|
+
entries.each do |entry|
|
|
105
|
+
subscription.graph.nearest_frame_nodes_from(entry.owner_id).each do |frame|
|
|
106
|
+
bucket = frames_by_id[frame.id]
|
|
107
|
+
bucket[:node] ||= frame
|
|
108
|
+
bucket[:dependency_keys] << entry.dependency_cache_key
|
|
109
|
+
bucket[:entries] << entry
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
remove_contained_frames(subscription.graph, frames_by_id.values.map { |bucket| bucket.fetch(:node) }, changes: changes).filter_map do |frame|
|
|
114
|
+
bucket = frames_by_id.fetch(frame.id)
|
|
115
|
+
build_target(subscription, frame, bucket.fetch(:dependency_keys).uniq, bucket.fetch(:entries), changes, shared_delivery: shared_delivery)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def remove_contained_frames(graph, frames, changes:)
|
|
120
|
+
frames = prefer_render_sites_for_destroy(graph, frames.uniq(&:id), changes)
|
|
121
|
+
|
|
122
|
+
frames.reject do |frame|
|
|
123
|
+
frames.any? { |candidate| candidate.id != frame.id && graph.contained_by?(frame.id, candidate.id) }
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def prefer_render_sites_for_destroy(graph, frames, changes)
|
|
128
|
+
return frames unless changes.any? { |change| destroy_change?(change) }
|
|
129
|
+
|
|
130
|
+
render_sites = frames.select { |frame| frame.payload.fetch(:kind) == "render_site" }
|
|
131
|
+
return frames if render_sites.empty?
|
|
132
|
+
|
|
133
|
+
frames.reject do |frame|
|
|
134
|
+
frame.payload.fetch(:kind) == "page" &&
|
|
135
|
+
render_sites.any? { |render_site| graph.contained_by?(render_site.id, frame.id) }
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def build_target(subscription, frame, dependency_keys, entries, changes, shared_delivery:)
|
|
140
|
+
target = target_for_frame(frame)
|
|
141
|
+
return unless target
|
|
142
|
+
|
|
143
|
+
frame_id = Targeting::Extraction.frame_id_for(target)
|
|
144
|
+
recipe = subscription.replay_recipe(frame_id)
|
|
145
|
+
return unless recipe
|
|
146
|
+
|
|
147
|
+
identity_signature = subscription.identity_signature(frame_id)
|
|
148
|
+
sharing_signature = SharedStreams.signature_for(recipe) if shared_delivery && identity_signature == "public" && frame.payload.fetch(:kind) == "render_site"
|
|
149
|
+
action, recipe, delivery_target, deoptimization_reason = cached_delivery_strategy(frame, recipe, entries, changes, sharing_signature: sharing_signature)
|
|
150
|
+
target = delivery_target || target
|
|
151
|
+
subscriber_ids = represented_subscriber_ids(subscription, entries)
|
|
152
|
+
|
|
153
|
+
PlannedTarget.new(
|
|
154
|
+
subscription.id,
|
|
155
|
+
subscription.subscriber_id,
|
|
156
|
+
subscriber_ids,
|
|
157
|
+
target,
|
|
158
|
+
frame_id,
|
|
159
|
+
identity_signature,
|
|
160
|
+
sharing_signature,
|
|
161
|
+
recipe,
|
|
162
|
+
dependency_keys,
|
|
163
|
+
action,
|
|
164
|
+
deoptimization_reason
|
|
165
|
+
)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def represented_subscriber_ids(subscription, entries)
|
|
169
|
+
subscriber_ids = entries.flat_map(&:represented_subscriber_ids).uniq.sort_by(&:to_s)
|
|
170
|
+
return [subscription.subscriber_id] if subscriber_ids.empty?
|
|
171
|
+
|
|
172
|
+
subscriber_ids
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def cached_delivery_strategy(frame, recipe, entries, changes, sharing_signature:)
|
|
176
|
+
key = delivery_strategy_cache_key(frame, recipe, entries, changes, sharing_signature)
|
|
177
|
+
return delivery_strategy(frame, recipe, entries, changes) unless key
|
|
178
|
+
|
|
179
|
+
@delivery_strategy_cache.fetch(key) do
|
|
180
|
+
@delivery_strategy_cache[key] = delivery_strategy(frame, recipe, entries, changes)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def delivery_strategy_cache_key(frame, recipe, entries, changes, sharing_signature)
|
|
185
|
+
return unless sharing_signature
|
|
186
|
+
|
|
187
|
+
[
|
|
188
|
+
frame.payload.fetch(:kind),
|
|
189
|
+
frame.payload.fetch(:site_id),
|
|
190
|
+
sharing_signature,
|
|
191
|
+
entries.map { |entry| entry.dependency_cache_key.inspect }.uniq.sort,
|
|
192
|
+
changes.map { |change| change.slice(:type, :table, :id, :changed_attributes).inspect }.sort
|
|
193
|
+
]
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def target_for_frame(frame)
|
|
197
|
+
case frame.payload.fetch(:kind)
|
|
198
|
+
when "page"
|
|
199
|
+
Targeting::Target.new("page", frame.id, "page frame dependency matched committed change")
|
|
200
|
+
when "render_site"
|
|
201
|
+
Targeting::Target.new("render_site", frame.payload.fetch(:site_id), "render-site dependency matched committed change")
|
|
202
|
+
when "fragment"
|
|
203
|
+
Targeting::Target.new("fragment", frame.id, "record attribute read matched committed attributes")
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def delivery_strategy(frame, recipe, entries, changes)
|
|
208
|
+
remove_recipe = remove_recipe_for(frame, recipe, entries, changes)
|
|
209
|
+
if remove_recipe
|
|
210
|
+
return [
|
|
211
|
+
"remove",
|
|
212
|
+
remove_recipe,
|
|
213
|
+
target_for_recipe(remove_recipe, "render-site member was destroyed"),
|
|
214
|
+
nil
|
|
215
|
+
]
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
append_recipe = append_recipe_for(frame, recipe, entries, changes)
|
|
219
|
+
return ["append", append_recipe, nil, nil] if append_recipe
|
|
220
|
+
|
|
221
|
+
prepend_recipe = prepend_recipe_for(frame, recipe, entries, changes)
|
|
222
|
+
return ["prepend", prepend_recipe, nil, nil] if prepend_recipe
|
|
223
|
+
|
|
224
|
+
member_replace_recipe = member_replace_recipe_for(frame, recipe, entries, changes)
|
|
225
|
+
if member_replace_recipe
|
|
226
|
+
delivery_target = target_for_recipe(member_replace_recipe, "render-site member update kept collection order")
|
|
227
|
+
return ["replace", member_replace_recipe, delivery_target, nil]
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
["replace", recipe, nil, deoptimization_reason(frame, entries, changes)]
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def append_recipe_for(frame, recipe, entries, changes)
|
|
234
|
+
return unless frame.payload.fetch(:kind) == "render_site"
|
|
235
|
+
return unless entries.any? { |entry| entry.dependency.source == :active_record_collection }
|
|
236
|
+
|
|
237
|
+
create_changes = changes.select { |change| change[:id] && change.fetch(:type).to_s.include?("create") }
|
|
238
|
+
return unless create_changes.one?
|
|
239
|
+
|
|
240
|
+
CollectionAppend.build(recipe: recipe, change: create_changes.first)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def prepend_recipe_for(frame, recipe, entries, changes)
|
|
244
|
+
return unless frame.payload.fetch(:kind) == "render_site"
|
|
245
|
+
return unless entries.any? { |entry| entry.dependency.source == :active_record_collection }
|
|
246
|
+
|
|
247
|
+
create_changes = changes.select { |change| change[:id] && change.fetch(:type).to_s.include?("create") }
|
|
248
|
+
return unless create_changes.one?
|
|
249
|
+
|
|
250
|
+
CollectionPrepend.build(recipe: recipe, change: create_changes.first)
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def member_replace_recipe_for(frame, recipe, entries, changes)
|
|
254
|
+
return unless frame.payload.fetch(:kind) == "render_site"
|
|
255
|
+
return unless entries.any? { |entry| entry.dependency.source == :active_record_collection }
|
|
256
|
+
|
|
257
|
+
update_changes = changes.select do |change|
|
|
258
|
+
change[:id] && !change.fetch(:type).to_s.include?("create") && !destroy_change?(change)
|
|
259
|
+
end
|
|
260
|
+
return unless update_changes.one?
|
|
261
|
+
|
|
262
|
+
CollectionMemberReplace.build(recipe: recipe, change: update_changes.first)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def remove_recipe_for(frame, recipe, entries, changes)
|
|
266
|
+
return unless frame.payload.fetch(:kind) == "render_site"
|
|
267
|
+
return unless entries.any? { |entry| entry.dependency.source == :active_record_collection }
|
|
268
|
+
|
|
269
|
+
destroy_changes = changes.select do |change|
|
|
270
|
+
change[:id] && destroy_change?(change)
|
|
271
|
+
end
|
|
272
|
+
return unless destroy_changes.one?
|
|
273
|
+
|
|
274
|
+
CollectionRemove.build(recipe: recipe, change: destroy_changes.first)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def deoptimization_reason(frame, entries, changes)
|
|
278
|
+
return unless frame.payload.fetch(:kind) == "render_site"
|
|
279
|
+
return unless entries.any? { |entry| entry.dependency.source == :active_record_collection }
|
|
280
|
+
|
|
281
|
+
if changes.one? { |change| change[:id] && change.fetch(:type).to_s.include?("create") }
|
|
282
|
+
"collection_create_position_unproven"
|
|
283
|
+
elsif changes.one? { |change| change[:id] && destroy_change?(change) }
|
|
284
|
+
"collection_remove_unproven"
|
|
285
|
+
elsif changes.one? { |change| change[:id] && !change.fetch(:type).to_s.include?("create") && !destroy_change?(change) }
|
|
286
|
+
"collection_member_replace_unproven"
|
|
287
|
+
else
|
|
288
|
+
"collection_multi_change_fallback"
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def destroy_change?(change)
|
|
293
|
+
type = change.fetch(:type).to_s
|
|
294
|
+
type.include?("destroy") || type.include?("delete")
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def target_for_recipe(recipe, reason)
|
|
298
|
+
Targeting::Target.new(recipe.target_kind, recipe.target_id, reason)
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def deduplicate_targets(targets)
|
|
302
|
+
targets.each_with_object({}) do |target, indexed_targets|
|
|
303
|
+
key = deduplication_key(target)
|
|
304
|
+
indexed_targets[key] = merge_target(indexed_targets[key], target)
|
|
305
|
+
end.values
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def deduplication_key(target)
|
|
309
|
+
subscriber_key = target.sharing_signature ? "shared" : target.subscriber_id
|
|
310
|
+
|
|
311
|
+
[
|
|
312
|
+
subscriber_key,
|
|
313
|
+
target.target.kind,
|
|
314
|
+
target.target.id,
|
|
315
|
+
target.identity_signature,
|
|
316
|
+
target.sharing_signature,
|
|
317
|
+
target.action,
|
|
318
|
+
target.deoptimization_reason
|
|
319
|
+
]
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def merge_target(existing, target)
|
|
323
|
+
return target unless existing
|
|
324
|
+
|
|
325
|
+
PlannedTarget.new(
|
|
326
|
+
existing.subscription_id,
|
|
327
|
+
existing.subscriber_id,
|
|
328
|
+
(existing.subscriber_ids + target.subscriber_ids).uniq.sort_by(&:to_s),
|
|
329
|
+
existing.target,
|
|
330
|
+
existing.frame_id,
|
|
331
|
+
existing.identity_signature,
|
|
332
|
+
existing.sharing_signature,
|
|
333
|
+
existing.recipe,
|
|
334
|
+
(existing.matched_dependency_keys + target.matched_dependency_keys).uniq,
|
|
335
|
+
existing.action,
|
|
336
|
+
existing.deoptimization_reason
|
|
337
|
+
)
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
end
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "invalidation/collection_append"
|
|
4
|
+
require_relative "invalidation/collection_member_replace"
|
|
5
|
+
require_relative "invalidation/collection_prepend"
|
|
6
|
+
require_relative "invalidation/collection_remove"
|
|
7
|
+
require_relative "invalidation/planner"
|