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,392 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_record"
|
|
4
|
+
|
|
5
|
+
module Upkeep
|
|
6
|
+
module ActiveRecordQuery
|
|
7
|
+
UNKNOWN_PREDICATE_VALUE = Object.new.freeze
|
|
8
|
+
|
|
9
|
+
class OpaqueRelationError < StandardError
|
|
10
|
+
attr_reader :model_name, :table_name, :sql, :reasons
|
|
11
|
+
|
|
12
|
+
def initialize(relation, reasons:)
|
|
13
|
+
@model_name = relation.klass.name
|
|
14
|
+
@table_name = relation.klass.table_name
|
|
15
|
+
@sql = relation.to_sql
|
|
16
|
+
@reasons = reasons
|
|
17
|
+
|
|
18
|
+
super(build_message)
|
|
19
|
+
rescue StandardError => error
|
|
20
|
+
super("Upkeep cannot prove this Active Record relation's structural dependencies: #{error.message}")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def build_message
|
|
26
|
+
<<~MESSAGE
|
|
27
|
+
Upkeep cannot make this Active Record relation reactive because its query shape is opaque.
|
|
28
|
+
|
|
29
|
+
Relation:
|
|
30
|
+
#{model_name} (#{table_name})
|
|
31
|
+
|
|
32
|
+
SQL:
|
|
33
|
+
#{sql}
|
|
34
|
+
|
|
35
|
+
Why:
|
|
36
|
+
#{reasons.map { |reason| " - #{reason}" }.join("\n")}
|
|
37
|
+
|
|
38
|
+
What to do:
|
|
39
|
+
- Rewrite raw SQL predicates with structural Active Record hash or Arel predicates.
|
|
40
|
+
- Rewrite raw SQL joins or FROM sources with structural Active Record/Arel joins.
|
|
41
|
+
- Render this boundary outside Upkeep reactivity when the query cannot expose its sources.
|
|
42
|
+
MESSAGE
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
public
|
|
46
|
+
|
|
47
|
+
def suggestions
|
|
48
|
+
[
|
|
49
|
+
"Rewrite raw SQL predicates with structural Active Record hash or Arel predicates.",
|
|
50
|
+
"Rewrite raw SQL joins or FROM sources with structural Active Record/Arel joins.",
|
|
51
|
+
"Render this boundary outside Upkeep reactivity when the query cannot expose its sources."
|
|
52
|
+
]
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
Result = Data.define(
|
|
57
|
+
:primary_table,
|
|
58
|
+
:table_columns,
|
|
59
|
+
:coverage,
|
|
60
|
+
:sql,
|
|
61
|
+
:primary_key,
|
|
62
|
+
:appendable,
|
|
63
|
+
:limit_value,
|
|
64
|
+
:predicates
|
|
65
|
+
) do
|
|
66
|
+
def tables = table_columns.keys.sort
|
|
67
|
+
|
|
68
|
+
def appendable?
|
|
69
|
+
appendable
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
module_function
|
|
74
|
+
|
|
75
|
+
def analyze(relation, opaque_table_policy: :raise)
|
|
76
|
+
collector = Collector.new(relation, opaque_table_policy: opaque_table_policy)
|
|
77
|
+
collector.analyze
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
class Collector
|
|
81
|
+
def initialize(relation, opaque_table_policy:)
|
|
82
|
+
@relation = relation
|
|
83
|
+
@opaque_table_policy = opaque_table_policy
|
|
84
|
+
@primary_table = relation.klass.table_name
|
|
85
|
+
@primary_key = relation.klass.primary_key
|
|
86
|
+
@table_columns = Hash.new { |hash, table| hash[table] = [] }
|
|
87
|
+
@table_aliases = {}
|
|
88
|
+
@opaque_columns = false
|
|
89
|
+
@opaque_tables = false
|
|
90
|
+
@opaque_table_reasons = []
|
|
91
|
+
@opaque_column_reasons = []
|
|
92
|
+
@predicate_groups = []
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def analyze
|
|
96
|
+
table(@primary_table)
|
|
97
|
+
collect_relation_shape
|
|
98
|
+
raise_opaque_relation! if opaque_relation? && @opaque_table_policy == :raise
|
|
99
|
+
|
|
100
|
+
Result.new(
|
|
101
|
+
primary_table: @primary_table,
|
|
102
|
+
table_columns: normalized_table_columns,
|
|
103
|
+
coverage: coverage,
|
|
104
|
+
sql: safe_sql,
|
|
105
|
+
primary_key: @primary_key,
|
|
106
|
+
appendable: appendable_relation?,
|
|
107
|
+
limit_value: @relation.limit_value,
|
|
108
|
+
predicates: normalized_predicates
|
|
109
|
+
)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
def collect_relation_shape
|
|
115
|
+
ast = @relation.arel.ast
|
|
116
|
+
|
|
117
|
+
ast.cores.each do |core|
|
|
118
|
+
walk(core.source, source: true)
|
|
119
|
+
walk(core.wheres)
|
|
120
|
+
record_predicate_groups(core.wheres)
|
|
121
|
+
walk(core.groups)
|
|
122
|
+
walk(core.havings)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
walk(ast.orders)
|
|
126
|
+
walk(ast.with) if ast.respond_to?(:with)
|
|
127
|
+
rescue StandardError => error
|
|
128
|
+
opaque_table!("relation AST could not be inspected (#{error.class}: #{error.message})")
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def coverage
|
|
132
|
+
return :tables if @opaque_tables || @opaque_columns
|
|
133
|
+
|
|
134
|
+
:columns
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def normalized_table_columns
|
|
138
|
+
table(@primary_table)
|
|
139
|
+
column(@primary_table, @primary_key) if @primary_key
|
|
140
|
+
|
|
141
|
+
@table_columns.transform_values { |columns| columns.compact.uniq.sort }.sort.to_h
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def appendable_relation?
|
|
145
|
+
return false unless coverage == :columns
|
|
146
|
+
return false if @opaque_tables
|
|
147
|
+
return false if @relation.limit_value || @relation.offset_value
|
|
148
|
+
return false if @relation.distinct_value
|
|
149
|
+
return false if @relation.group_values.any?
|
|
150
|
+
return false if !@relation.having_clause.empty?
|
|
151
|
+
|
|
152
|
+
true
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def walk(value, source: false)
|
|
156
|
+
case value
|
|
157
|
+
when nil, true, false, Numeric, Symbol, Class, Module
|
|
158
|
+
nil
|
|
159
|
+
when Array
|
|
160
|
+
value.each { |entry| walk(entry, source: source) }
|
|
161
|
+
when Hash
|
|
162
|
+
value.each_value { |entry| walk(entry, source: source) }
|
|
163
|
+
when defined?(Arel::Nodes::Quoted) && Arel::Nodes::Quoted
|
|
164
|
+
nil
|
|
165
|
+
when defined?(Arel::Nodes::Casted) && Arel::Nodes::Casted
|
|
166
|
+
nil
|
|
167
|
+
when Arel::Attributes::Attribute
|
|
168
|
+
attribute(value)
|
|
169
|
+
when Arel::Nodes::Equality
|
|
170
|
+
walk(value.left, source: source)
|
|
171
|
+
walk(value.right, source: source) if value.right.is_a?(Arel::Attributes::Attribute)
|
|
172
|
+
when defined?(Arel::Nodes::NotEqual) && Arel::Nodes::NotEqual
|
|
173
|
+
walk(value.left, source: source)
|
|
174
|
+
walk(value.right, source: source) if value.right.is_a?(Arel::Attributes::Attribute)
|
|
175
|
+
when Arel::Nodes::HomogeneousIn
|
|
176
|
+
walk(value.attribute, source: source)
|
|
177
|
+
when defined?(Arel::Nodes::In) && Arel::Nodes::In
|
|
178
|
+
walk(value.left, source: source)
|
|
179
|
+
walk(value.right, source: source) if value.right.is_a?(Arel::Attributes::Attribute)
|
|
180
|
+
when Arel::Nodes::Matches, Arel::Nodes::DoesNotMatch
|
|
181
|
+
walk(value.left, source: source)
|
|
182
|
+
walk(value.right, source: source) if value.right.is_a?(Arel::Attributes::Attribute)
|
|
183
|
+
when Arel::Nodes::NamedFunction
|
|
184
|
+
walk(value.expressions, source: source)
|
|
185
|
+
when Arel::Nodes::InfixOperation
|
|
186
|
+
# Operator-style expressions (jsonb `->>`/`#>>`, arithmetic, etc.). The reactive
|
|
187
|
+
# surface is the operands; the operator itself is a SQL token, not a data
|
|
188
|
+
# dependency, so walk left/right and ignore the operator (mirrors NamedFunction).
|
|
189
|
+
walk(value.left, source: source)
|
|
190
|
+
walk(value.right, source: source)
|
|
191
|
+
when Arel::Table
|
|
192
|
+
table(value.name)
|
|
193
|
+
when Arel::Nodes::TableAlias
|
|
194
|
+
table_alias(value)
|
|
195
|
+
when Arel::Nodes::StringJoin
|
|
196
|
+
opaque_table!("raw SQL join")
|
|
197
|
+
when Arel::Nodes::BoundSqlLiteral, Arel::Nodes::SqlLiteral
|
|
198
|
+
source ? opaque_table!("raw SQL source") : opaque_column!("raw SQL predicate or order expression")
|
|
199
|
+
when String
|
|
200
|
+
source ? opaque_table!("string SQL source") : opaque_column!("string SQL predicate or order expression")
|
|
201
|
+
else
|
|
202
|
+
walk_arel_node(value, source: source)
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def walk_arel_node(value, source:)
|
|
207
|
+
return unless value.is_a?(Arel::Nodes::Node)
|
|
208
|
+
|
|
209
|
+
value.instance_variables.each do |ivar|
|
|
210
|
+
walk(value.instance_variable_get(ivar), source: source)
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def attribute(value)
|
|
215
|
+
table_name = table_name_for(value.relation)
|
|
216
|
+
return opaque_table!("attribute references an unknown table source") unless table_name
|
|
217
|
+
return if value.name.to_s == "*"
|
|
218
|
+
|
|
219
|
+
column(table_name, value.name)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def table_name_for(relation)
|
|
223
|
+
if relation.is_a?(Arel::Nodes::TableAlias)
|
|
224
|
+
table_name_for(relation.left)
|
|
225
|
+
elsif relation.respond_to?(:name)
|
|
226
|
+
name = relation.name.to_s
|
|
227
|
+
@table_aliases.fetch(name, name)
|
|
228
|
+
elsif relation.respond_to?(:left)
|
|
229
|
+
table_name_for(relation.left)
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def table_alias(value)
|
|
234
|
+
table_name = table_name_for(value.left)
|
|
235
|
+
return opaque_table!("table alias references an unknown table source") unless table_name
|
|
236
|
+
|
|
237
|
+
@table_aliases[value.right.to_s] = table_name
|
|
238
|
+
table(table_name)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def opaque_table!(reason)
|
|
242
|
+
@opaque_tables = true
|
|
243
|
+
@opaque_table_reasons << reason
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def opaque_column!(reason)
|
|
247
|
+
@opaque_columns = true
|
|
248
|
+
@opaque_column_reasons << reason
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def opaque_relation?
|
|
252
|
+
@opaque_tables || @opaque_columns
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def raise_opaque_relation!
|
|
256
|
+
raise OpaqueRelationError.new(@relation, reasons: (@opaque_table_reasons + @opaque_column_reasons).uniq)
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def table(name)
|
|
260
|
+
@table_columns[name.to_s]
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def column(table_name, column_name)
|
|
264
|
+
@table_columns[table_name.to_s] << column_name.to_s
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def record_predicate_groups(value)
|
|
268
|
+
groups = predicate_groups_for(value)
|
|
269
|
+
return if groups.empty?
|
|
270
|
+
|
|
271
|
+
@predicate_groups = and_predicate_groups(@predicate_groups, groups)
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def predicate_groups_for(value)
|
|
275
|
+
case value
|
|
276
|
+
when nil, true, false, Numeric, Symbol, Class, Module, String
|
|
277
|
+
[]
|
|
278
|
+
when Array
|
|
279
|
+
value.reduce([]) do |groups, entry|
|
|
280
|
+
and_predicate_groups(groups, predicate_groups_for(entry))
|
|
281
|
+
end
|
|
282
|
+
when Hash
|
|
283
|
+
predicate_groups_for(value.values)
|
|
284
|
+
when Arel::Nodes::Equality
|
|
285
|
+
group_for_predicate(predicate_for(value.left, "eq", [predicate_value(value.right)]))
|
|
286
|
+
when defined?(Arel::Nodes::NotEqual) && Arel::Nodes::NotEqual
|
|
287
|
+
group_for_predicate(predicate_for(value.left, "not_eq", [predicate_value(value.right)]))
|
|
288
|
+
when Arel::Nodes::HomogeneousIn
|
|
289
|
+
group_for_predicate(predicate_for(value.attribute, homogeneous_in_operator(value), Array(value.values).map { |entry| predicate_value(entry) }))
|
|
290
|
+
when defined?(Arel::Nodes::In) && Arel::Nodes::In
|
|
291
|
+
group_for_predicate(predicate_for(value.left, "in", Array(value.right).map { |entry| predicate_value(entry) }))
|
|
292
|
+
when Arel::Nodes::Grouping
|
|
293
|
+
predicate_groups_for(value.expr)
|
|
294
|
+
when Arel::Nodes::And
|
|
295
|
+
predicate_groups_for(value.children)
|
|
296
|
+
when Arel::Nodes::Or
|
|
297
|
+
child_groups = or_children(value).map { |child| predicate_groups_for(child) }
|
|
298
|
+
return [] if child_groups.any?(&:empty?)
|
|
299
|
+
|
|
300
|
+
child_groups.flatten(1)
|
|
301
|
+
else
|
|
302
|
+
[]
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def homogeneous_in_operator(node)
|
|
307
|
+
node.respond_to?(:type) && node.type.to_sym == :notin ? "not_in" : "in"
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def or_children(node)
|
|
311
|
+
if node.respond_to?(:children)
|
|
312
|
+
node.children
|
|
313
|
+
else
|
|
314
|
+
[node.left, node.right]
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def group_for_predicate(predicate)
|
|
319
|
+
predicate ? [[predicate]] : []
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def and_predicate_groups(left_groups, right_groups)
|
|
323
|
+
return right_groups if left_groups.empty?
|
|
324
|
+
return left_groups if right_groups.empty?
|
|
325
|
+
|
|
326
|
+
left_groups.flat_map do |left_group|
|
|
327
|
+
right_groups.map { |right_group| left_group + right_group }
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def predicate_for(attribute, operator, values)
|
|
332
|
+
return unless attribute.is_a?(Arel::Attributes::Attribute)
|
|
333
|
+
|
|
334
|
+
table_name = table_name_for(attribute.relation)
|
|
335
|
+
return unless table_name
|
|
336
|
+
|
|
337
|
+
values = values.reject { |value| value.equal?(UNKNOWN_PREDICATE_VALUE) }
|
|
338
|
+
return if values.empty?
|
|
339
|
+
|
|
340
|
+
{
|
|
341
|
+
table: table_name.to_s,
|
|
342
|
+
column: attribute.name.to_s,
|
|
343
|
+
operator: operator,
|
|
344
|
+
values: values.uniq
|
|
345
|
+
}
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def predicate_value(value)
|
|
349
|
+
if value.respond_to?(:value_for_database)
|
|
350
|
+
value.value_for_database
|
|
351
|
+
elsif value.respond_to?(:value)
|
|
352
|
+
value.value
|
|
353
|
+
elsif value.nil? || value.is_a?(String) || value.is_a?(Numeric) || value == true || value == false || value.is_a?(Symbol)
|
|
354
|
+
value
|
|
355
|
+
else
|
|
356
|
+
UNKNOWN_PREDICATE_VALUE
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def normalized_predicates
|
|
361
|
+
grouped = normalized_predicate_groups
|
|
362
|
+
predicates = if grouped.one?
|
|
363
|
+
grouped.first
|
|
364
|
+
else
|
|
365
|
+
grouped.each_with_index.flat_map do |group, index|
|
|
366
|
+
group.map { |predicate| predicate.merge(group: index) }
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
predicates
|
|
371
|
+
.uniq
|
|
372
|
+
.sort_by { |predicate| [predicate.fetch(:group, -1), predicate.fetch(:table), predicate.fetch(:column), predicate.fetch(:operator), predicate.fetch(:values).inspect] }
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def normalized_predicate_groups
|
|
376
|
+
@predicate_groups
|
|
377
|
+
.map do |group|
|
|
378
|
+
group
|
|
379
|
+
.uniq
|
|
380
|
+
.sort_by { |predicate| [predicate.fetch(:table), predicate.fetch(:column), predicate.fetch(:operator), predicate.fetch(:values).inspect] }
|
|
381
|
+
end
|
|
382
|
+
.uniq
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
def safe_sql
|
|
386
|
+
@relation.to_sql
|
|
387
|
+
rescue StandardError => error
|
|
388
|
+
"#{error.class}: #{error.message}"
|
|
389
|
+
end
|
|
390
|
+
end
|
|
391
|
+
end
|
|
392
|
+
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
|