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.
Files changed (65) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +311 -0
  4. data/docs/how-it-works.md +269 -0
  5. data/lib/generators/upkeep/install/install_generator.rb +127 -0
  6. data/lib/generators/upkeep/install/templates/create_upkeep_subscriptions.rb.erb +49 -0
  7. data/lib/generators/upkeep/install/templates/subscription.js +99 -0
  8. data/lib/generators/upkeep/install/templates/upkeep.rb +63 -0
  9. data/lib/upkeep/active_record_query.rb +392 -0
  10. data/lib/upkeep/capture/request.rb +150 -0
  11. data/lib/upkeep/dag/subscription_shape.rb +244 -0
  12. data/lib/upkeep/dag.rb +370 -0
  13. data/lib/upkeep/delivery/action_cable_adapter.rb +43 -0
  14. data/lib/upkeep/delivery/async_dispatcher.rb +102 -0
  15. data/lib/upkeep/delivery/broadcast_transport.rb +89 -0
  16. data/lib/upkeep/delivery/transport.rb +194 -0
  17. data/lib/upkeep/delivery/turbo_streams.rb +302 -0
  18. data/lib/upkeep/delivery.rb +7 -0
  19. data/lib/upkeep/dependencies.rb +550 -0
  20. data/lib/upkeep/herb/developer_report.rb +135 -0
  21. data/lib/upkeep/herb/manifest_cache.rb +83 -0
  22. data/lib/upkeep/herb/manifest_diff.rb +183 -0
  23. data/lib/upkeep/herb/source_instrumenter.rb +149 -0
  24. data/lib/upkeep/herb/template_manifest.rb +518 -0
  25. data/lib/upkeep/invalidation/collection_append.rb +84 -0
  26. data/lib/upkeep/invalidation/collection_member_replace.rb +78 -0
  27. data/lib/upkeep/invalidation/collection_prepend.rb +84 -0
  28. data/lib/upkeep/invalidation/collection_remove.rb +57 -0
  29. data/lib/upkeep/invalidation/planner.rb +360 -0
  30. data/lib/upkeep/invalidation.rb +7 -0
  31. data/lib/upkeep/rails/action_view_capture.rb +920 -0
  32. data/lib/upkeep/rails/activation_token.rb +55 -0
  33. data/lib/upkeep/rails/cable/channel.rb +143 -0
  34. data/lib/upkeep/rails/cable/subscriber_identity.rb +341 -0
  35. data/lib/upkeep/rails/cable.rb +4 -0
  36. data/lib/upkeep/rails/client_subscription.rb +45 -0
  37. data/lib/upkeep/rails/configuration.rb +245 -0
  38. data/lib/upkeep/rails/controller_runtime.rb +154 -0
  39. data/lib/upkeep/rails/delivery_job.rb +29 -0
  40. data/lib/upkeep/rails/install.rb +28 -0
  41. data/lib/upkeep/rails/railtie.rb +50 -0
  42. data/lib/upkeep/rails/replay.rb +197 -0
  43. data/lib/upkeep/rails/testing.rb +258 -0
  44. data/lib/upkeep/rails.rb +370 -0
  45. data/lib/upkeep/replay.rb +439 -0
  46. data/lib/upkeep/runtime.rb +1202 -0
  47. data/lib/upkeep/shared_streams.rb +72 -0
  48. data/lib/upkeep/subscriptions/active_record_store.rb +387 -0
  49. data/lib/upkeep/subscriptions/active_record_subscription_persistence.rb +407 -0
  50. data/lib/upkeep/subscriptions/active_registry.rb +87 -0
  51. data/lib/upkeep/subscriptions/async_durable_writer.rb +131 -0
  52. data/lib/upkeep/subscriptions/json_snapshot.rb +98 -0
  53. data/lib/upkeep/subscriptions/layered_reverse_index.rb +129 -0
  54. data/lib/upkeep/subscriptions/persistent_reverse_index.rb +223 -0
  55. data/lib/upkeep/subscriptions/registrar.rb +36 -0
  56. data/lib/upkeep/subscriptions/reverse_index.rb +301 -0
  57. data/lib/upkeep/subscriptions/shape.rb +116 -0
  58. data/lib/upkeep/subscriptions/store.rb +375 -0
  59. data/lib/upkeep/subscriptions.rb +7 -0
  60. data/lib/upkeep/targeting.rb +135 -0
  61. data/lib/upkeep/version.rb +5 -0
  62. data/lib/upkeep-rails.rb +3 -0
  63. data/lib/upkeep.rb +14 -0
  64. data/upkeep-rails.gemspec +54 -0
  65. 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