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,294 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_record"
|
|
4
|
+
|
|
5
|
+
module Upkeep
|
|
6
|
+
module ActiveRecordQuery
|
|
7
|
+
class OpaqueRelationError < StandardError
|
|
8
|
+
attr_reader :model_name, :table_name, :sql, :reasons
|
|
9
|
+
|
|
10
|
+
def initialize(relation, reasons:)
|
|
11
|
+
@model_name = relation.klass.name
|
|
12
|
+
@table_name = relation.klass.table_name
|
|
13
|
+
@sql = relation.to_sql
|
|
14
|
+
@reasons = reasons
|
|
15
|
+
|
|
16
|
+
super(build_message)
|
|
17
|
+
rescue StandardError => error
|
|
18
|
+
super("Upkeep cannot prove this Active Record relation's structural dependencies: #{error.message}")
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def build_message
|
|
24
|
+
<<~MESSAGE
|
|
25
|
+
Upkeep cannot make this Active Record relation reactive because its query shape is opaque.
|
|
26
|
+
|
|
27
|
+
Relation:
|
|
28
|
+
#{model_name} (#{table_name})
|
|
29
|
+
|
|
30
|
+
SQL:
|
|
31
|
+
#{sql}
|
|
32
|
+
|
|
33
|
+
Why:
|
|
34
|
+
#{reasons.map { |reason| " - #{reason}" }.join("\n")}
|
|
35
|
+
|
|
36
|
+
What to do:
|
|
37
|
+
- Rewrite raw SQL predicates with structural Active Record hash or Arel predicates.
|
|
38
|
+
- Rewrite raw SQL joins or FROM sources with structural Active Record/Arel joins.
|
|
39
|
+
- Render this boundary outside Upkeep reactivity when the query cannot expose its sources.
|
|
40
|
+
MESSAGE
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
public
|
|
44
|
+
|
|
45
|
+
def suggestions
|
|
46
|
+
[
|
|
47
|
+
"Rewrite raw SQL predicates with structural Active Record hash or Arel predicates.",
|
|
48
|
+
"Rewrite raw SQL joins or FROM sources with structural Active Record/Arel joins.",
|
|
49
|
+
"Render this boundary outside Upkeep reactivity when the query cannot expose its sources."
|
|
50
|
+
]
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
Result = Data.define(
|
|
55
|
+
:primary_table,
|
|
56
|
+
:table_columns,
|
|
57
|
+
:coverage,
|
|
58
|
+
:sql,
|
|
59
|
+
:primary_key,
|
|
60
|
+
:appendable,
|
|
61
|
+
:limit_value,
|
|
62
|
+
:predicates
|
|
63
|
+
) do
|
|
64
|
+
def tables = table_columns.keys.sort
|
|
65
|
+
|
|
66
|
+
def appendable?
|
|
67
|
+
appendable
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
module_function
|
|
72
|
+
|
|
73
|
+
def analyze(relation, opaque_table_policy: :raise)
|
|
74
|
+
collector = Collector.new(relation, opaque_table_policy: opaque_table_policy)
|
|
75
|
+
collector.analyze
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
class Collector
|
|
79
|
+
def initialize(relation, opaque_table_policy:)
|
|
80
|
+
@relation = relation
|
|
81
|
+
@opaque_table_policy = opaque_table_policy
|
|
82
|
+
@primary_table = relation.klass.table_name
|
|
83
|
+
@primary_key = relation.klass.primary_key
|
|
84
|
+
@table_columns = Hash.new { |hash, table| hash[table] = [] }
|
|
85
|
+
@table_aliases = {}
|
|
86
|
+
@opaque_columns = false
|
|
87
|
+
@opaque_tables = false
|
|
88
|
+
@opaque_table_reasons = []
|
|
89
|
+
@opaque_column_reasons = []
|
|
90
|
+
@predicates = []
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def analyze
|
|
94
|
+
table(@primary_table)
|
|
95
|
+
collect_relation_shape
|
|
96
|
+
raise_opaque_relation! if opaque_relation? && @opaque_table_policy == :raise
|
|
97
|
+
|
|
98
|
+
Result.new(
|
|
99
|
+
primary_table: @primary_table,
|
|
100
|
+
table_columns: normalized_table_columns,
|
|
101
|
+
coverage: coverage,
|
|
102
|
+
sql: safe_sql,
|
|
103
|
+
primary_key: @primary_key,
|
|
104
|
+
appendable: appendable_relation?,
|
|
105
|
+
limit_value: @relation.limit_value,
|
|
106
|
+
predicates: normalized_predicates
|
|
107
|
+
)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
def collect_relation_shape
|
|
113
|
+
ast = @relation.arel.ast
|
|
114
|
+
|
|
115
|
+
ast.cores.each do |core|
|
|
116
|
+
walk(core.source, source: true)
|
|
117
|
+
walk(core.wheres)
|
|
118
|
+
walk(core.groups)
|
|
119
|
+
walk(core.havings)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
walk(ast.orders)
|
|
123
|
+
walk(ast.with) if ast.respond_to?(:with)
|
|
124
|
+
rescue StandardError => error
|
|
125
|
+
opaque_table!("relation AST could not be inspected (#{error.class}: #{error.message})")
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def coverage
|
|
129
|
+
return :tables if @opaque_tables || @opaque_columns
|
|
130
|
+
|
|
131
|
+
:columns
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def normalized_table_columns
|
|
135
|
+
table(@primary_table)
|
|
136
|
+
column(@primary_table, @primary_key) if @primary_key
|
|
137
|
+
|
|
138
|
+
@table_columns.transform_values { |columns| columns.compact.uniq.sort }.sort.to_h
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def appendable_relation?
|
|
142
|
+
return false unless coverage == :columns
|
|
143
|
+
return false if @opaque_tables
|
|
144
|
+
return false if @relation.limit_value || @relation.offset_value
|
|
145
|
+
return false if @relation.distinct_value
|
|
146
|
+
return false if @relation.group_values.any?
|
|
147
|
+
return false if !@relation.having_clause.empty?
|
|
148
|
+
|
|
149
|
+
true
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def walk(value, source: false)
|
|
153
|
+
case value
|
|
154
|
+
when nil, true, false, Numeric, Symbol, Class, Module
|
|
155
|
+
nil
|
|
156
|
+
when Array
|
|
157
|
+
value.each { |entry| walk(entry, source: source) }
|
|
158
|
+
when Hash
|
|
159
|
+
value.each_value { |entry| walk(entry, source: source) }
|
|
160
|
+
when Arel::Attributes::Attribute
|
|
161
|
+
attribute(value)
|
|
162
|
+
when Arel::Nodes::Equality
|
|
163
|
+
equality_predicate(value)
|
|
164
|
+
walk_arel_node(value, source: source)
|
|
165
|
+
when Arel::Nodes::HomogeneousIn
|
|
166
|
+
homogeneous_in_predicate(value)
|
|
167
|
+
walk_arel_node(value, source: source)
|
|
168
|
+
when Arel::Table
|
|
169
|
+
table(value.name)
|
|
170
|
+
when Arel::Nodes::TableAlias
|
|
171
|
+
table_alias(value)
|
|
172
|
+
when Arel::Nodes::StringJoin
|
|
173
|
+
opaque_table!("raw SQL join")
|
|
174
|
+
when Arel::Nodes::BoundSqlLiteral, Arel::Nodes::SqlLiteral
|
|
175
|
+
source ? opaque_table!("raw SQL source") : opaque_column!("raw SQL predicate or order expression")
|
|
176
|
+
when String
|
|
177
|
+
source ? opaque_table!("string SQL source") : opaque_column!("string SQL predicate or order expression")
|
|
178
|
+
else
|
|
179
|
+
walk_arel_node(value, source: source)
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def walk_arel_node(value, source:)
|
|
184
|
+
return unless value.is_a?(Arel::Nodes::Node)
|
|
185
|
+
|
|
186
|
+
value.instance_variables.each do |ivar|
|
|
187
|
+
walk(value.instance_variable_get(ivar), source: source)
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def attribute(value)
|
|
192
|
+
table_name = table_name_for(value.relation)
|
|
193
|
+
return opaque_table!("attribute references an unknown table source") unless table_name
|
|
194
|
+
return if value.name.to_s == "*"
|
|
195
|
+
|
|
196
|
+
column(table_name, value.name)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def table_name_for(relation)
|
|
200
|
+
if relation.is_a?(Arel::Nodes::TableAlias)
|
|
201
|
+
table_name_for(relation.left)
|
|
202
|
+
elsif relation.respond_to?(:name)
|
|
203
|
+
name = relation.name.to_s
|
|
204
|
+
@table_aliases.fetch(name, name)
|
|
205
|
+
elsif relation.respond_to?(:left)
|
|
206
|
+
table_name_for(relation.left)
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def table_alias(value)
|
|
211
|
+
table_name = table_name_for(value.left)
|
|
212
|
+
return opaque_table!("table alias references an unknown table source") unless table_name
|
|
213
|
+
|
|
214
|
+
@table_aliases[value.right.to_s] = table_name
|
|
215
|
+
table(table_name)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def opaque_table!(reason)
|
|
219
|
+
@opaque_tables = true
|
|
220
|
+
@opaque_table_reasons << reason
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def opaque_column!(reason)
|
|
224
|
+
@opaque_columns = true
|
|
225
|
+
@opaque_column_reasons << reason
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def opaque_relation?
|
|
229
|
+
@opaque_tables || @opaque_columns
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def raise_opaque_relation!
|
|
233
|
+
raise OpaqueRelationError.new(@relation, reasons: (@opaque_table_reasons + @opaque_column_reasons).uniq)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def table(name)
|
|
237
|
+
@table_columns[name.to_s]
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def column(table_name, column_name)
|
|
241
|
+
@table_columns[table_name.to_s] << column_name.to_s
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def equality_predicate(node)
|
|
245
|
+
predicate = predicate_for(node.left, "eq", [predicate_value(node.right)])
|
|
246
|
+
@predicates << predicate if predicate
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def homogeneous_in_predicate(node)
|
|
250
|
+
predicate = predicate_for(node.attribute, "in", Array(node.values).map { |value| predicate_value(value) })
|
|
251
|
+
@predicates << predicate if predicate
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def predicate_for(attribute, operator, values)
|
|
255
|
+
return unless attribute.is_a?(Arel::Attributes::Attribute)
|
|
256
|
+
|
|
257
|
+
table_name = table_name_for(attribute.relation)
|
|
258
|
+
return unless table_name
|
|
259
|
+
|
|
260
|
+
values = values.compact
|
|
261
|
+
return if values.empty?
|
|
262
|
+
|
|
263
|
+
{
|
|
264
|
+
table: table_name.to_s,
|
|
265
|
+
column: attribute.name.to_s,
|
|
266
|
+
operator: operator,
|
|
267
|
+
values: values.uniq
|
|
268
|
+
}
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def predicate_value(value)
|
|
272
|
+
if value.respond_to?(:value_for_database)
|
|
273
|
+
value.value_for_database
|
|
274
|
+
elsif value.respond_to?(:value)
|
|
275
|
+
value.value
|
|
276
|
+
elsif value.nil? || value.is_a?(String) || value.is_a?(Numeric) || value == true || value == false || value.is_a?(Symbol)
|
|
277
|
+
value
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def normalized_predicates
|
|
282
|
+
@predicates
|
|
283
|
+
.uniq
|
|
284
|
+
.sort_by { |predicate| [predicate.fetch(:table), predicate.fetch(:column), predicate.fetch(:operator), predicate.fetch(:values).inspect] }
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def safe_sql
|
|
288
|
+
@relation.to_sql
|
|
289
|
+
rescue StandardError => error
|
|
290
|
+
"#{error.class}: #{error.message}"
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
end
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Upkeep
|
|
4
|
+
module Capture
|
|
5
|
+
RequestSignature = Data.define(:controller, :action, :method, :fullpath)
|
|
6
|
+
|
|
7
|
+
RequestResult = Data.define(
|
|
8
|
+
:action_result,
|
|
9
|
+
:html,
|
|
10
|
+
:recorder,
|
|
11
|
+
:response_status,
|
|
12
|
+
:response_content_type,
|
|
13
|
+
:response_media_type,
|
|
14
|
+
:response_successful,
|
|
15
|
+
:signature,
|
|
16
|
+
:timings,
|
|
17
|
+
:counters
|
|
18
|
+
) do
|
|
19
|
+
def successful?
|
|
20
|
+
!!response_successful
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def html_response?
|
|
24
|
+
response_media_type == "text/html" ||
|
|
25
|
+
response_content_type.to_s.start_with?("text/html")
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
module Request
|
|
30
|
+
module_function
|
|
31
|
+
|
|
32
|
+
def call(controller, profile: false)
|
|
33
|
+
timings = {}
|
|
34
|
+
counters = {}
|
|
35
|
+
action_result, recorder = measure(timings, :action_ms) do
|
|
36
|
+
if profile
|
|
37
|
+
profile_action(timings, counters) do
|
|
38
|
+
Runtime::Observation.capture_request(profile: true) { yield }
|
|
39
|
+
end
|
|
40
|
+
else
|
|
41
|
+
Runtime::Observation.capture_request { yield }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
timings.merge!(recorder.profile_timings)
|
|
45
|
+
counters.merge!(recorder.profile_counts)
|
|
46
|
+
html = measure(timings, :response_body_ms) { response_body_html(controller.response.body) }
|
|
47
|
+
signature = measure(timings, :signature_ms) { signature_for(controller) }
|
|
48
|
+
RequestResult.new(
|
|
49
|
+
action_result,
|
|
50
|
+
html,
|
|
51
|
+
recorder,
|
|
52
|
+
controller.response.status,
|
|
53
|
+
controller.response.content_type,
|
|
54
|
+
controller.response.media_type,
|
|
55
|
+
controller.response.successful?,
|
|
56
|
+
signature,
|
|
57
|
+
timings,
|
|
58
|
+
counters
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def profile_action(timings, counters)
|
|
63
|
+
collector = ActionProfiler.new
|
|
64
|
+
collector.capture { yield }.tap do
|
|
65
|
+
timings.merge!(collector.timings)
|
|
66
|
+
counters.merge!(collector.counters)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def signature_for(controller)
|
|
71
|
+
request = controller.request
|
|
72
|
+
RequestSignature.new(
|
|
73
|
+
controller.class.name,
|
|
74
|
+
controller.action_name,
|
|
75
|
+
request.request_method,
|
|
76
|
+
request.fullpath
|
|
77
|
+
)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def response_body_html(body)
|
|
81
|
+
case body
|
|
82
|
+
when String
|
|
83
|
+
body
|
|
84
|
+
when Array
|
|
85
|
+
body.join
|
|
86
|
+
else
|
|
87
|
+
return body.body.join if body.respond_to?(:body) && body.body.respond_to?(:join)
|
|
88
|
+
return body.to_a.join if body.respond_to?(:to_a)
|
|
89
|
+
|
|
90
|
+
body.to_s
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def measure(timings, key)
|
|
95
|
+
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
96
|
+
yield
|
|
97
|
+
ensure
|
|
98
|
+
timings[key] = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000.0).round(3)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
class ActionProfiler
|
|
102
|
+
EVENT_MAP = {
|
|
103
|
+
"sql.active_record" => :sql,
|
|
104
|
+
"render_template.action_view" => :render_template,
|
|
105
|
+
"render_partial.action_view" => :render_partial,
|
|
106
|
+
"render_collection.action_view" => :render_collection
|
|
107
|
+
}.freeze
|
|
108
|
+
|
|
109
|
+
attr_reader :timings, :counters
|
|
110
|
+
|
|
111
|
+
def initialize
|
|
112
|
+
@thread = Thread.current
|
|
113
|
+
@timings = Hash.new(0.0)
|
|
114
|
+
@counters = Hash.new(0)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def capture
|
|
118
|
+
callback = lambda do |name, started, finished, unique_id, payload|
|
|
119
|
+
next unless Thread.current.equal?(@thread)
|
|
120
|
+
|
|
121
|
+
event = ActiveSupport::Notifications::Event.new(name, started, finished, unique_id, payload)
|
|
122
|
+
record(event)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
ActiveSupport::Notifications.subscribed(callback, /\A(sql\.active_record|render_(template|partial|collection)\.action_view)\z/) do
|
|
126
|
+
yield
|
|
127
|
+
end
|
|
128
|
+
ensure
|
|
129
|
+
@timings.transform_values! { |value| value.round(3) }
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
private
|
|
133
|
+
|
|
134
|
+
def record(event)
|
|
135
|
+
key = EVENT_MAP[event.name]
|
|
136
|
+
return unless key
|
|
137
|
+
return if ignored_sql?(event)
|
|
138
|
+
|
|
139
|
+
@timings[:"#{key}_ms"] += event.duration
|
|
140
|
+
@counters[:"#{key}_count"] += 1
|
|
141
|
+
@timings[:view_ms] += event.duration if event.name.end_with?(".action_view")
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def ignored_sql?(event)
|
|
145
|
+
event.name == "sql.active_record" && event.payload[:name] == "SCHEMA"
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require_relative "../shared_streams"
|
|
5
|
+
require_relative "../version"
|
|
6
|
+
|
|
7
|
+
module Upkeep
|
|
8
|
+
module DAG
|
|
9
|
+
class SubscriptionShape
|
|
10
|
+
DIGEST_SCOPE = "upkeep-subscription-shape"
|
|
11
|
+
FRAME_PAYLOAD_SHAPE_IGNORED_KEYS = %i[manifest recipe].freeze
|
|
12
|
+
|
|
13
|
+
attr_reader :signature
|
|
14
|
+
|
|
15
|
+
def self.from_graph(graph, request_signature: nil)
|
|
16
|
+
new(signature: signature_for_terms(request_signature, graph_terms(graph)))
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.from_components(graph_component, request_signature: nil)
|
|
20
|
+
new(signature: signature_for_terms(request_signature, terms_for_component(graph_component)))
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.from_terms(graph_terms, request_signature: nil)
|
|
24
|
+
new(signature: signature_for_terms(request_signature, graph_terms))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.from_trace_digest(trace_digest, request_signature: nil)
|
|
28
|
+
new(signature: signature_for_trace_digest(request_signature, trace_digest))
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.signature_for_terms(request_signature, graph_terms)
|
|
32
|
+
digest = Digest::SHA256.new
|
|
33
|
+
digest.update(DIGEST_SCOPE)
|
|
34
|
+
digest.update("\0")
|
|
35
|
+
digest.update(Upkeep::VERSION)
|
|
36
|
+
digest.update("\0")
|
|
37
|
+
digest.update(canonical_value(request_signature_component(request_signature)))
|
|
38
|
+
%i[frames dependencies contains].each do |group|
|
|
39
|
+
digest.update("\0")
|
|
40
|
+
digest.update(group.to_s)
|
|
41
|
+
Array(graph_terms[group]).each do |term|
|
|
42
|
+
digest.update("\0")
|
|
43
|
+
digest.update(term)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
digest.hexdigest
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.signature_for_trace_digest(request_signature, trace_digest)
|
|
50
|
+
digest = Digest::SHA256.new
|
|
51
|
+
digest.update(DIGEST_SCOPE)
|
|
52
|
+
digest.update("\0")
|
|
53
|
+
digest.update(Upkeep::VERSION)
|
|
54
|
+
digest.update("\0")
|
|
55
|
+
digest.update(canonical_value(request_signature_component(request_signature)))
|
|
56
|
+
digest.update("\0")
|
|
57
|
+
digest.update(trace_digest)
|
|
58
|
+
digest.hexdigest
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def self.request_signature_component(signature)
|
|
62
|
+
return nil unless signature
|
|
63
|
+
|
|
64
|
+
signature.respond_to?(:to_h) ? signature.to_h : signature
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def self.graph_component(graph)
|
|
68
|
+
{
|
|
69
|
+
frames: graph.frame_nodes.map { |node| frame_component(node.id, node.payload) }.sort_by { |component| component.fetch(:id).to_s },
|
|
70
|
+
dependencies: graph.dependency_nodes.map { |node| dependency_component(graph, node) }.sort_by { |component| component.fetch(:id).inspect },
|
|
71
|
+
contains: graph.edges
|
|
72
|
+
.select { |edge| edge.reason == :contains }
|
|
73
|
+
.map { |edge| [edge.from, edge.to] }
|
|
74
|
+
.sort_by(&:inspect)
|
|
75
|
+
}
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def self.graph_terms(graph)
|
|
79
|
+
{
|
|
80
|
+
frames: graph.frame_nodes.map { |node| frame_term(node.id, node.payload) },
|
|
81
|
+
dependencies: graph.dependency_nodes.map { |node| dependency_term(node.id, node.payload, graph.dependency_owner_ids(node.id)) },
|
|
82
|
+
contains: graph.edges
|
|
83
|
+
.select { |edge| edge.reason == :contains }
|
|
84
|
+
.map { |edge| contains_term(edge.from, edge.to) }
|
|
85
|
+
}
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def self.terms_for_component(graph_component)
|
|
89
|
+
{
|
|
90
|
+
frames: graph_component.fetch(:frames).map { |component| canonical_term(:frame, component.fetch(:id), component.fetch(:payload)) },
|
|
91
|
+
dependencies: graph_component.fetch(:dependencies).map do |component|
|
|
92
|
+
canonical_term(:dependency, component.fetch(:id), component.fetch(:dependency), component.fetch(:owners))
|
|
93
|
+
end,
|
|
94
|
+
contains: graph_component.fetch(:contains).map { |from, to| contains_term(from, to) }
|
|
95
|
+
}
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def self.frame_component(id, payload)
|
|
99
|
+
{
|
|
100
|
+
id: id,
|
|
101
|
+
payload: frame_payload_component(payload)
|
|
102
|
+
}
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def self.frame_term(id, payload)
|
|
106
|
+
canonical_term(:frame, id, frame_payload_component(payload))
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def self.frame_payload_component(payload)
|
|
110
|
+
component = payload.reject do |key, _value|
|
|
111
|
+
key.respond_to?(:to_sym) && FRAME_PAYLOAD_SHAPE_IGNORED_KEYS.include?(key.to_sym)
|
|
112
|
+
end
|
|
113
|
+
component = shape_value(component)
|
|
114
|
+
recipe = payload[:recipe] || payload["recipe"]
|
|
115
|
+
kind = payload[:kind] || payload["kind"]
|
|
116
|
+
if recipe && kind.to_s == "render_site"
|
|
117
|
+
component[:shared_stream_signature] = SharedStreams.signature_for(recipe)
|
|
118
|
+
end
|
|
119
|
+
component
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def self.dependency_component(graph, node)
|
|
123
|
+
{
|
|
124
|
+
id: node.id,
|
|
125
|
+
dependency: shape_value(node.payload.to_h),
|
|
126
|
+
owners: graph.dependency_owner_ids(node.id).sort_by(&:to_s)
|
|
127
|
+
}
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def self.dependency_term(id, dependency, owners)
|
|
131
|
+
canonical_term(:dependency, id, dependency.to_h, owners.sort_by(&:to_s))
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def self.contains_term(from, to)
|
|
135
|
+
canonical_term(:contains, from, to)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def self.shape_value(value)
|
|
139
|
+
case value
|
|
140
|
+
when Hash
|
|
141
|
+
value.keys.sort_by(&:to_s).to_h { |key| [key, shape_value(value.fetch(key))] }
|
|
142
|
+
when Array
|
|
143
|
+
value.map { |item| shape_value(item) }
|
|
144
|
+
else
|
|
145
|
+
value.respond_to?(:to_h) ? shape_value(value.to_h) : value
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def self.canonical_term(*parts)
|
|
150
|
+
parts.map { |part| canonical_value(part) }.join("\0")
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def self.canonical_value(value)
|
|
154
|
+
shape_value(value).inspect
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def initialize(signature:)
|
|
158
|
+
@signature = signature
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
class Trace
|
|
162
|
+
def initialize(graph_version:)
|
|
163
|
+
@graph_version = graph_version
|
|
164
|
+
@seen_frame_ids = {}
|
|
165
|
+
@seen_dependency_keys = {}
|
|
166
|
+
@seen_dependency_owner_ids_by_key = Hash.new { |owners, dependency_key| owners[dependency_key] = {} }
|
|
167
|
+
@seen_contains_edges = {}
|
|
168
|
+
@digest = Digest::SHA256.new
|
|
169
|
+
@digest.update("subscription-shape-trace")
|
|
170
|
+
@invalid = false
|
|
171
|
+
@recorded = false
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def synchronized_with?(graph)
|
|
175
|
+
!@invalid && @graph_version == graph.version
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def invalidate!
|
|
179
|
+
@invalid = true
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def record_frame(frame_id, metadata, parent_id:, graph_version:)
|
|
183
|
+
return if @invalid
|
|
184
|
+
|
|
185
|
+
unless @seen_frame_ids.key?(frame_id)
|
|
186
|
+
@seen_frame_ids[frame_id] = true
|
|
187
|
+
record_digest_term(:frame, SubscriptionShape.frame_term(frame_id, metadata))
|
|
188
|
+
end
|
|
189
|
+
edge_key = [parent_id, frame_id]
|
|
190
|
+
unless @seen_contains_edges.key?(edge_key)
|
|
191
|
+
@seen_contains_edges[edge_key] = true
|
|
192
|
+
record_digest_term(:contains, SubscriptionShape.contains_term(parent_id, frame_id))
|
|
193
|
+
end
|
|
194
|
+
@recorded = true
|
|
195
|
+
@graph_version = graph_version
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def record_dependency(owner_id, dependency, graph_version:)
|
|
199
|
+
return if @invalid
|
|
200
|
+
|
|
201
|
+
dependency_cache_key = dependency.cache_key
|
|
202
|
+
unless @seen_dependency_keys.key?(dependency_cache_key)
|
|
203
|
+
@seen_dependency_keys[dependency_cache_key] = true
|
|
204
|
+
dependency_payload = SubscriptionShape.shape_value(dependency.to_h)
|
|
205
|
+
record_digest_term(:dependency, SubscriptionShape.canonical_term(:dependency, dependency_cache_key, dependency_payload))
|
|
206
|
+
end
|
|
207
|
+
unless @seen_dependency_owner_ids_by_key[dependency_cache_key].key?(owner_id)
|
|
208
|
+
@seen_dependency_owner_ids_by_key[dependency_cache_key][owner_id] = true
|
|
209
|
+
record_digest_term(:dependency_owner, SubscriptionShape.canonical_term(:dependency_owner, dependency_cache_key, owner_id))
|
|
210
|
+
end
|
|
211
|
+
@recorded = true
|
|
212
|
+
@graph_version = graph_version
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def covers?(graph)
|
|
216
|
+
synchronized_with?(graph) && (recorded? || graph_shape_empty?(graph))
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def subscription_shape(request_signature: nil)
|
|
220
|
+
SubscriptionShape.from_trace_digest(@digest.hexdigest, request_signature: request_signature)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
private
|
|
224
|
+
|
|
225
|
+
def recorded?
|
|
226
|
+
@recorded
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def graph_shape_empty?(graph)
|
|
230
|
+
graph.frame_nodes.empty? &&
|
|
231
|
+
graph.dependency_nodes.empty? &&
|
|
232
|
+
graph.edges.none? { |edge| edge.reason == :contains }
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def record_digest_term(kind, term)
|
|
236
|
+
@digest.update("\0")
|
|
237
|
+
@digest.update(kind.to_s)
|
|
238
|
+
@digest.update("\0")
|
|
239
|
+
@digest.update(term)
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
end
|