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,1202 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+ require "active_support/current_attributes"
5
+ require "active_support/notifications"
6
+ require "digest"
7
+ require_relative "active_record_query"
8
+
9
+ module Upkeep
10
+ module Runtime
11
+ module Observation
12
+ THREAD_KEY = :upkeep_recorder
13
+
14
+ module_function
15
+
16
+ def capture_request(profile: false)
17
+ previous = Thread.current[THREAD_KEY]
18
+ recorder = Recorder.new(profile: profile)
19
+ Thread.current[THREAD_KEY] = recorder
20
+
21
+ result = yield(recorder)
22
+ recorder.flush_pending_dependencies
23
+ [result, recorder]
24
+ ensure
25
+ Thread.current[THREAD_KEY] = previous
26
+ end
27
+
28
+ def capture_frame(frame_id, metadata = {})
29
+ recorder = Thread.current[THREAD_KEY]
30
+ return yield unless recorder
31
+
32
+ recorder.with_frame(frame_id, metadata) { yield }
33
+ end
34
+
35
+ def record_dependency(dependency)
36
+ Thread.current[THREAD_KEY]&.record_dependency(dependency)
37
+ end
38
+
39
+ def record_ambient_replay_input(source, key, value)
40
+ Thread.current[THREAD_KEY]&.record_ambient_replay_input(source, key, value)
41
+ end
42
+
43
+ def record_relation_provenance(collection, model_name:, analysis:)
44
+ Thread.current[THREAD_KEY]&.record_relation_provenance(collection, model_name: model_name, analysis: analysis)
45
+ end
46
+
47
+ def relation_provenance_for(collection)
48
+ Thread.current[THREAD_KEY]&.relation_provenance_for(collection)
49
+ end
50
+
51
+ def refuse_boundary(boundary)
52
+ Thread.current[THREAD_KEY]&.refuse_boundary(**boundary)
53
+ end
54
+
55
+ def recorder
56
+ Thread.current[THREAD_KEY]
57
+ end
58
+
59
+ def recording?
60
+ !!Thread.current[THREAD_KEY]
61
+ end
62
+
63
+ end
64
+
65
+ RelationProvenance = Data.define(:model_name, :analysis) do
66
+ def primary_table = analysis.primary_table
67
+ def table_columns = analysis.table_columns
68
+ def coverage = analysis.coverage
69
+ def sql = analysis.sql
70
+ def primary_key = analysis.primary_key
71
+ def predicates = analysis.predicates
72
+ def appendable? = analysis.appendable?
73
+ def limit_value = analysis.limit_value
74
+ end
75
+
76
+ class Recorder
77
+ REQUEST_NODE_ID = :request
78
+ RefusedBoundary = Data.define(:reason, :message, :suggestions, :source)
79
+
80
+ attr_reader :graph, :refused_boundaries
81
+
82
+ def initialize(graph: nil, profile: false)
83
+ @frame_stack = []
84
+ @graph = graph || DAG::Graph.new
85
+ @profile = profile
86
+ @profile_timings = Hash.new(0.0)
87
+ @profile_counts = Hash.new(0)
88
+ @refused_boundaries = []
89
+ @ambient_replay_inputs_by_owner = Hash.new do |owners, owner_id|
90
+ owners[owner_id] = Hash.new { |sources, source| sources[source] = {} }
91
+ end
92
+ @pending_dependencies_by_owner = Hash.new { |owners, owner_id| owners[owner_id] = {} }
93
+ @relation_provenance_by_collection_id = {}
94
+ @graph.add_node(REQUEST_NODE_ID, kind: :request, payload: {}) unless @graph.node?(REQUEST_NODE_ID)
95
+ @subscription_shape_trace = DAG::SubscriptionShape::Trace.new(graph_version: @graph.version)
96
+ end
97
+
98
+ def self.from_h(snapshot)
99
+ snapshot = Dependencies.symbolize_keys(snapshot)
100
+ new(graph: DAG::Graph.from_h(snapshot.fetch(:graph)))
101
+ end
102
+
103
+ def to_h(dependencies: :all)
104
+ flush_pending_dependencies
105
+ { graph: graph.to_h(dependencies: dependencies) }
106
+ end
107
+
108
+ def to_persistent_h
109
+ to_h(dependencies: :identity)
110
+ end
111
+
112
+ def with_frame(frame_id, metadata)
113
+ profile_count(:recorder_frame_count)
114
+ parent_id = nil
115
+ profile_timing(:recorder_frame_ms) do
116
+ invalidate_subscription_shape_trace_if_needed
117
+ parent_id = current_owner
118
+ @graph.add_node(frame_id, kind: :frame, payload: metadata)
119
+ @graph.add_edge(parent_id, frame_id, reason: :contains)
120
+ profile_timing(:recorder_shape_trace_ms) do
121
+ @subscription_shape_trace.record_frame(frame_id, metadata, parent_id: parent_id, graph_version: @graph.version)
122
+ end
123
+ end
124
+ @frame_stack.push(frame_id)
125
+ begin
126
+ yield
127
+ ensure
128
+ flush_pending_dependencies(frame_id)
129
+ @frame_stack.pop
130
+ end
131
+ end
132
+
133
+ def flush_pending_dependencies(owner_id = nil)
134
+ if owner_id
135
+ flush_pending_dependencies_for(owner_id)
136
+ else
137
+ @pending_dependencies_by_owner.keys.each { |pending_owner_id| flush_pending_dependencies_for(pending_owner_id) }
138
+ end
139
+ ensure
140
+ @pending_dependencies_by_owner.delete(owner_id) if owner_id
141
+ end
142
+
143
+ def record_dependency(dependency)
144
+ profile_count(:recorder_dependency_count)
145
+ profile_timing(:recorder_dependency_ms) do
146
+ owner_id = current_owner
147
+ @pending_dependencies_by_owner[owner_id][dependency.cache_key] ||= dependency
148
+ end
149
+ end
150
+
151
+ def subscription_shape(request_signature: nil)
152
+ flush_pending_dependencies
153
+ return @subscription_shape_trace.subscription_shape(request_signature: request_signature) if @subscription_shape_trace.covers?(@graph)
154
+
155
+ DAG::SubscriptionShape.from_graph(@graph, request_signature: request_signature)
156
+ end
157
+
158
+ def record_ambient_replay_input(source, key, value)
159
+ profile_count(:recorder_ambient_replay_input_count)
160
+ profile_timing(:recorder_ambient_replay_input_ms) do
161
+ @ambient_replay_inputs_by_owner[current_owner][source.to_sym][key.to_s] = value
162
+ end
163
+ end
164
+
165
+ def ambient_replay_inputs_for(owner_id)
166
+ @ambient_replay_inputs_by_owner.fetch(owner_id, {}).each_with_object({}) do |(source, values), inputs|
167
+ inputs[source] = values.dup
168
+ end
169
+ end
170
+
171
+ def record_relation_provenance(collection, model_name:, analysis:)
172
+ return unless collection && analysis
173
+
174
+ profile_count(:recorder_relation_provenance_count)
175
+ profile_timing(:recorder_relation_provenance_ms) do
176
+ @relation_provenance_by_collection_id[collection.object_id] =
177
+ RelationProvenance.new(model_name.to_s, analysis)
178
+ end
179
+ end
180
+
181
+ def relation_provenance_for(collection)
182
+ @relation_provenance_by_collection_id[collection.object_id] if collection
183
+ end
184
+
185
+ def refuse_boundary(reason:, message:, suggestions:, source:)
186
+ profile_count(:recorder_refused_boundary_count)
187
+ profile_timing(:recorder_refused_boundary_ms) do
188
+ boundary = RefusedBoundary.new(
189
+ reason.to_s,
190
+ message.to_s,
191
+ Array(suggestions).map(&:to_s),
192
+ source.to_s
193
+ )
194
+ return false if @refused_boundaries.include?(boundary)
195
+
196
+ @refused_boundaries << boundary
197
+ true
198
+ end
199
+ end
200
+
201
+ def profile_timings
202
+ @profile_timings.transform_values { |value| value.round(3) }
203
+ end
204
+
205
+ def profile_counts
206
+ @profile_counts.dup
207
+ end
208
+
209
+ def reactive?
210
+ @refused_boundaries.empty?
211
+ end
212
+
213
+ def current_frame
214
+ @frame_stack.last
215
+ end
216
+
217
+ def current_owner
218
+ current_frame || REQUEST_NODE_ID
219
+ end
220
+
221
+ def identity_profile(frame_id)
222
+ flush_pending_dependencies
223
+ identity_dependencies_for(frame_id).map(&:to_h)
224
+ end
225
+
226
+ def identity_signature(frame_id)
227
+ flush_pending_dependencies
228
+ identity_dependencies = identity_dependencies_for(frame_id)
229
+ return "public" if identity_dependencies.empty?
230
+
231
+ Digest::SHA256.hexdigest(identity_dependencies.map(&:identity_key).sort_by(&:inspect).inspect)[0, 16]
232
+ end
233
+
234
+ private
235
+
236
+ def invalidate_subscription_shape_trace_if_needed
237
+ @subscription_shape_trace.invalidate! unless @subscription_shape_trace.synchronized_with?(@graph)
238
+ end
239
+
240
+ def flush_pending_dependencies_for(owner_id)
241
+ dependencies = @pending_dependencies_by_owner.delete(owner_id)
242
+ return unless dependencies&.any?
243
+
244
+ profile_timing(:recorder_dependency_ms) do
245
+ dependencies.each_value do |dependency|
246
+ profile_count(:recorder_dependency_flush_count)
247
+ invalidate_subscription_shape_trace_if_needed
248
+ if @graph.add_dependency(owner_id, dependency)
249
+ profile_timing(:recorder_shape_trace_ms) do
250
+ @subscription_shape_trace.record_dependency(owner_id, dependency, graph_version: @graph.version)
251
+ end
252
+ end
253
+ end
254
+ end
255
+ end
256
+
257
+ def profile_timing(key)
258
+ return yield unless @profile
259
+
260
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
261
+ yield
262
+ ensure
263
+ if @profile && started_at
264
+ @profile_timings[key] += (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000.0
265
+ end
266
+ end
267
+
268
+ def profile_count(key)
269
+ return unless @profile
270
+
271
+ @profile_counts[key] += 1
272
+ end
273
+
274
+ def identity_dependencies_for(frame_id)
275
+ identity_dependency_owner_ids(frame_id)
276
+ .flat_map { |owner_id| @graph.dependencies_for(owner_id) }
277
+ .select { |dependency| Dependencies.partitioning_identity?(dependency) }
278
+ .uniq(&:cache_key)
279
+ end
280
+
281
+ def identity_dependency_owner_ids(frame_id)
282
+ owner_ids = @graph.contained_node_ids(frame_id)
283
+ frame = @graph.node(frame_id)
284
+
285
+ if frame.kind == :frame && frame.payload[:kind] == "page"
286
+ owner_ids.concat(@graph.ancestor_node_ids(frame_id))
287
+ end
288
+
289
+ owner_ids
290
+ rescue KeyError
291
+ owner_ids
292
+ end
293
+ end
294
+
295
+ module ChangeLog
296
+ THREAD_KEY = :upkeep_change_log_events
297
+ @events = []
298
+ @mutex = Mutex.new
299
+
300
+ module_function
301
+
302
+ def reset
303
+ @mutex.synchronize { @events = [] }
304
+ Thread.current[THREAD_KEY] = nil
305
+ end
306
+
307
+ def record(event)
308
+ if (events = Thread.current[THREAD_KEY])
309
+ events << event
310
+ else
311
+ @mutex.synchronize { @events << event }
312
+ end
313
+ end
314
+
315
+ def events
316
+ if (events = Thread.current[THREAD_KEY])
317
+ events
318
+ else
319
+ @mutex.synchronize { @events.dup }
320
+ end
321
+ end
322
+
323
+ def drain
324
+ if (events = Thread.current[THREAD_KEY])
325
+ drained = events.dup
326
+ events.clear
327
+ drained
328
+ else
329
+ @mutex.synchronize do
330
+ events = @events
331
+ @events = []
332
+ events
333
+ end
334
+ end
335
+ end
336
+
337
+ def capture
338
+ previous = Thread.current[THREAD_KEY]
339
+ events = []
340
+ Thread.current[THREAD_KEY] = events
341
+
342
+ [yield, events.dup]
343
+ ensure
344
+ Thread.current[THREAD_KEY] = previous
345
+ end
346
+ end
347
+
348
+ module ChangeEvents
349
+ module_function
350
+
351
+ def active_record_commit(record)
352
+ return active_record_destroy(record) if record.destroyed?
353
+
354
+ attribute_changes = previous_changes(record.previous_changes)
355
+
356
+ {
357
+ type: created_record?(record, attribute_changes) ? "create" : "update",
358
+ table: record.class.table_name,
359
+ model: record.class.name,
360
+ id: record.id,
361
+ changed_attributes: attribute_changes.keys.sort,
362
+ old_values: attribute_changes.transform_values { |change| change.fetch(:old) },
363
+ new_values: attribute_changes.transform_values { |change| change.fetch(:new) },
364
+ attribute_changes: attribute_changes
365
+ }
366
+ end
367
+
368
+ def active_record_destroy(record)
369
+ old_values = record.attributes.transform_keys(&:to_s)
370
+
371
+ {
372
+ type: "destroy",
373
+ table: record.class.table_name,
374
+ model: record.class.name,
375
+ id: record.id,
376
+ changed_attributes: old_values.keys.sort,
377
+ old_values: old_values,
378
+ new_values: {},
379
+ attribute_changes: old_values.transform_values { |value| { old: value, new: nil } }
380
+ }
381
+ end
382
+
383
+ def active_record_update_columns(record, changed_attributes:, new_values: {})
384
+ changed_attributes = Array(changed_attributes).map(&:to_s).sort
385
+ new_values = new_values.transform_keys(&:to_s)
386
+
387
+ {
388
+ type: "update",
389
+ table: record.class.table_name,
390
+ model: record.class.name,
391
+ id: record.class.primary_key && record.public_send(record.class.primary_key),
392
+ changed_attributes: changed_attributes,
393
+ old_values: {},
394
+ new_values: new_values,
395
+ attribute_changes: changed_attributes.to_h do |attribute|
396
+ [attribute, { old: nil, new: new_values[attribute] }]
397
+ end
398
+ }
399
+ end
400
+
401
+ def bulk_update(table:, model:, changed_attributes:, predicate_sql:, predicate_coverage:, predicate_table_columns:, new_values: {}, id: nil)
402
+ changed_attributes = Array(changed_attributes).map(&:to_s).sort
403
+ new_values = new_values.transform_keys(&:to_s)
404
+
405
+ event = {
406
+ type: "bulk_update",
407
+ table: table,
408
+ model: model,
409
+ changed_attributes: changed_attributes,
410
+ old_values: {},
411
+ new_values: new_values,
412
+ attribute_changes: changed_attributes.to_h do |attribute|
413
+ [attribute, { old: nil, new: new_values[attribute] }]
414
+ end,
415
+ predicate_sql: predicate_sql,
416
+ predicate_coverage: predicate_coverage,
417
+ predicate_table_columns: predicate_table_columns
418
+ }
419
+ event[:id] = id unless id.nil?
420
+ event
421
+ end
422
+
423
+ def bulk_delete(table:, model:, changed_attributes:, predicate_sql:, predicate_coverage:, predicate_table_columns:, id: nil)
424
+ changed_attributes = Array(changed_attributes).map(&:to_s).sort
425
+
426
+ event = {
427
+ type: "bulk_delete",
428
+ table: table,
429
+ model: model,
430
+ changed_attributes: changed_attributes,
431
+ old_values: {},
432
+ new_values: {},
433
+ attribute_changes: changed_attributes.to_h { |attribute| [attribute, { old: nil, new: nil }] },
434
+ predicate_sql: predicate_sql,
435
+ predicate_coverage: predicate_coverage,
436
+ predicate_table_columns: predicate_table_columns
437
+ }
438
+ event[:id] = id unless id.nil?
439
+ event
440
+ end
441
+
442
+ def previous_changes(changes)
443
+ changes.to_h.transform_keys(&:to_s).transform_values do |(old_value, new_value)|
444
+ { old: old_value, new: new_value }
445
+ end
446
+ end
447
+
448
+ def created_record?(record, attribute_changes)
449
+ primary_key = record.class.primary_key
450
+ return false unless primary_key
451
+
452
+ primary_key_change = attribute_changes[primary_key.to_s]
453
+ primary_key_change && primary_key_change.fetch(:old).nil? && !primary_key_change.fetch(:new).nil?
454
+ end
455
+ end
456
+
457
+ module Ambient
458
+ module_function
459
+
460
+ def record_current_attribute(owner, name, value)
461
+ return unless Observation.recording?
462
+
463
+ dependency = Dependencies::CurrentAttribute.new(
464
+ owner: owner,
465
+ name: name,
466
+ value: value,
467
+ **identity_presence_metadata(:current, { owner: owner, name: name }, value)
468
+ )
469
+ Observation.record_dependency(dependency)
470
+ end
471
+
472
+ def record_session(key, value)
473
+ return unless Observation.recording?
474
+
475
+ dependency = Dependencies::SessionValue.new(
476
+ key: key,
477
+ value: value,
478
+ **identity_presence_metadata(:session, key, value)
479
+ )
480
+ Observation.record_dependency(dependency)
481
+ Observation.record_ambient_replay_input(:session, key, value)
482
+ end
483
+
484
+ def record_cookie(key, value)
485
+ return unless Observation.recording?
486
+
487
+ dependency = Dependencies::CookieValue.new(
488
+ key: key,
489
+ value: value,
490
+ **identity_presence_metadata(:cookie, key, value)
491
+ )
492
+ Observation.record_dependency(dependency)
493
+ Observation.record_ambient_replay_input(:cookie, key, value)
494
+ end
495
+
496
+ def record_request(key, value)
497
+ return unless Observation.recording?
498
+
499
+ dependency = Dependencies::RequestValue.new(key: key, value: value)
500
+ Observation.record_dependency(dependency)
501
+ Observation.record_ambient_replay_input(:request, key, value)
502
+ end
503
+
504
+ def record_warden_user(scope, user)
505
+ return unless Observation.recording?
506
+
507
+ dependency = Dependencies::WardenUser.new(
508
+ scope: scope,
509
+ user: user,
510
+ **identity_presence_metadata(:warden, scope, user)
511
+ )
512
+ Observation.record_dependency(dependency)
513
+ end
514
+
515
+ def identity_presence_metadata(source, key, value)
516
+ if defined?(Upkeep::Rails) && Upkeep::Rails.respond_to?(:configuration)
517
+ Upkeep::Rails.configuration.identity_presence_metadata(source: source, key: key, value: value)
518
+ else
519
+ { partitioning: !value.nil?, absent_by_name: {} }
520
+ end
521
+ end
522
+ end
523
+
524
+ class ObservedHash
525
+ def initialize(source:, values:)
526
+ @source = source
527
+ @values = values || {}
528
+ end
529
+
530
+ def [](key)
531
+ value = lookup(key)
532
+ record(key, value)
533
+ value
534
+ end
535
+
536
+ def fetch(key, *fallback)
537
+ if include_key?(key)
538
+ self[key]
539
+ elsif block_given?
540
+ yield key
541
+ elsif fallback.any?
542
+ fallback.first
543
+ else
544
+ @values.fetch(key)
545
+ end
546
+ end
547
+
548
+ def dig(first_key, *rest)
549
+ value = self[first_key]
550
+ rest.empty? || value.nil? ? value : value.dig(*rest)
551
+ end
552
+
553
+ private
554
+
555
+ def lookup(key)
556
+ return @values[key] if @values.key?(key)
557
+ return @values[key.to_s] if @values.key?(key.to_s)
558
+ return @values[key.to_sym] if key.respond_to?(:to_sym) && @values.key?(key.to_sym)
559
+
560
+ nil
561
+ end
562
+
563
+ def include_key?(key)
564
+ @values.key?(key) ||
565
+ @values.key?(key.to_s) ||
566
+ (key.respond_to?(:to_sym) && @values.key?(key.to_sym))
567
+ end
568
+
569
+ def record(key, value)
570
+ case @source
571
+ when :session
572
+ Ambient.record_session(key, value)
573
+ when :cookie
574
+ Ambient.record_cookie(key, value)
575
+ end
576
+ end
577
+ end
578
+
579
+ class ObservedRequest
580
+ def initialize(values)
581
+ @values = values || {}
582
+ end
583
+
584
+ def host = read(:host)
585
+
586
+ def subdomain = read(:subdomain)
587
+
588
+ def path = read(:path)
589
+
590
+ def fullpath = read(:fullpath)
591
+
592
+ def request_method = read(:request_method)
593
+
594
+ def user_agent = read(:user_agent)
595
+
596
+ def remote_ip = read(:remote_ip)
597
+
598
+ def params = read(:params)
599
+
600
+ def [](key)
601
+ read(key)
602
+ end
603
+
604
+ private
605
+
606
+ def read(key)
607
+ value = lookup(key)
608
+ Ambient.record_request(key, value)
609
+ value
610
+ end
611
+
612
+ def lookup(key)
613
+ return @values[key] if @values.key?(key)
614
+ return @values[key.to_s] if @values.key?(key.to_s)
615
+ return @values[key.to_sym] if key.respond_to?(:to_sym) && @values.key?(key.to_sym)
616
+
617
+ nil
618
+ end
619
+ end
620
+
621
+ class ObservedWarden
622
+ def initialize(users_by_scope)
623
+ @users_by_scope = users_by_scope || {}
624
+ end
625
+
626
+ def user(scope = :user, **options)
627
+ scope = options.fetch(:scope, scope)
628
+ value = @users_by_scope[scope] || @users_by_scope[scope.to_s] || @users_by_scope[scope.to_sym]
629
+ Ambient.record_warden_user(scope, value)
630
+ value
631
+ end
632
+
633
+ def authenticate(*args, **options)
634
+ user(extract_scope(args, options))
635
+ end
636
+
637
+ def authenticated?(*args, **options)
638
+ !user(extract_scope(args, options)).nil?
639
+ end
640
+
641
+ private
642
+
643
+ def extract_scope(args, options)
644
+ options.fetch(:scope) { args.first || :user }
645
+ end
646
+ end
647
+
648
+ module CurrentAttributesClassObserver
649
+ def attribute(*names, **options)
650
+ result = super
651
+ Runtime.wrap_current_attribute_readers(self, names)
652
+ result
653
+ end
654
+ end
655
+
656
+ module WardenObserver
657
+ def user(*args, **options, &block)
658
+ value = super
659
+ Runtime::Ambient.record_warden_user(warden_scope(args, options), value)
660
+ value
661
+ end
662
+
663
+ def authenticate(*args, **options, &block)
664
+ value = super
665
+ Runtime::Ambient.record_warden_user(warden_scope(args, options), value)
666
+ value
667
+ end
668
+
669
+ private
670
+
671
+ def warden_scope(args, options)
672
+ options.fetch(:scope) { args.first || :user }
673
+ end
674
+ end
675
+
676
+ module SessionObserver
677
+ def [](key)
678
+ value = super
679
+ Runtime::Ambient.record_session(key, value)
680
+ value
681
+ end
682
+
683
+ def fetch(key, *args, &block)
684
+ value = super
685
+ Runtime::Ambient.record_session(key, value)
686
+ value
687
+ end
688
+ end
689
+
690
+ module CookieObserver
691
+ def [](key)
692
+ value = super
693
+ Runtime::Ambient.record_cookie(key, value)
694
+ value
695
+ end
696
+ end
697
+
698
+ module RequestObserver
699
+ def host
700
+ value = super
701
+ Runtime::Ambient.record_request(:host, value)
702
+ value
703
+ end
704
+
705
+ def subdomain
706
+ value = super
707
+ Runtime::Ambient.record_request(:subdomain, value)
708
+ value
709
+ end
710
+
711
+ def path
712
+ value = super
713
+ Runtime::Ambient.record_request(:path, value)
714
+ value
715
+ end
716
+
717
+ def fullpath
718
+ value = super
719
+ Runtime::Ambient.record_request(:fullpath, value)
720
+ value
721
+ end
722
+
723
+ def request_method
724
+ value = super
725
+ Runtime::Ambient.record_request(:request_method, value)
726
+ value
727
+ end
728
+
729
+ def user_agent
730
+ value = super
731
+ Runtime::Ambient.record_request(:user_agent, value)
732
+ value
733
+ end
734
+
735
+ def remote_ip
736
+ value = super
737
+ Runtime::Ambient.record_request(:remote_ip, value)
738
+ value
739
+ end
740
+
741
+ def params
742
+ value = super
743
+ Runtime::Ambient.record_request(:params, value)
744
+ value
745
+ end
746
+ end
747
+
748
+ class Current
749
+ THREAD_KEY = :upkeep_current_user
750
+
751
+ class << self
752
+ def set(user:)
753
+ previous = Thread.current[THREAD_KEY]
754
+ Thread.current[THREAD_KEY] = user
755
+ yield
756
+ ensure
757
+ Thread.current[THREAD_KEY] = previous
758
+ end
759
+
760
+ def user
761
+ user = Thread.current[THREAD_KEY]
762
+ return user unless Observation.recording?
763
+
764
+ dependency = Dependencies::Identity.new(
765
+ source: "Current.user",
766
+ key: "id",
767
+ value: user&.id,
768
+ metadata: {
769
+ model: user&.class&.name,
770
+ table: user&.class&.table_name,
771
+ id: user&.id
772
+ }.compact
773
+ )
774
+
775
+ Observation.record_dependency(dependency)
776
+ user
777
+ end
778
+ end
779
+ end
780
+
781
+ module AttributeObserver
782
+ def _read_attribute(attr_name, &block)
783
+ value = super
784
+ return value unless Observation.recording?
785
+
786
+ dependency = Dependencies::ActiveRecordAttribute.new(
787
+ table: self.class.table_name,
788
+ model: self.class.name,
789
+ id: primary_key_value(attr_name, value),
790
+ attribute: attr_name.to_s
791
+ )
792
+
793
+ Observation.record_dependency(dependency)
794
+
795
+ value
796
+ end
797
+
798
+ private
799
+
800
+ def primary_key_value(attr_name, value)
801
+ primary_key = self.class.primary_key
802
+ return nil unless primary_key
803
+ return value if attr_name.to_s == primary_key.to_s
804
+
805
+ @attributes.fetch_value(primary_key)
806
+ rescue StandardError
807
+ nil
808
+ end
809
+ end
810
+
811
+ # Hooks `cache_key_with_version` so that any caller (Rails fragment caching,
812
+ # `Rails.cache.fetch("#{record.cache_key_with_version}/...")`, Russian doll,
813
+ # etc.) registers a dependency on the record's `updated_at` attribute. This
814
+ # lets Upkeep stay reactive across `Rails.cache.fetch` blocks: even on cache
815
+ # hits, the participating records are still declared as dependencies, so a
816
+ # touch/update broadcasts an update to subscribers viewing the cached
817
+ # fragment.
818
+ module CacheKeyObserver
819
+ def cache_key_with_version
820
+ record_upkeep_cache_key_dependency
821
+ super
822
+ end
823
+
824
+ private
825
+
826
+ def record_upkeep_cache_key_dependency
827
+ return unless Observation.recording?
828
+ return if new_record? || destroyed?
829
+
830
+ attribute = self.class.timestamp_attributes_for_update_in_model.first.to_s
831
+ Observation.record_dependency(
832
+ Dependencies::ActiveRecordAttribute.new(
833
+ table: self.class.table_name,
834
+ model: self.class.name,
835
+ id: id,
836
+ attribute: attribute
837
+ )
838
+ )
839
+ end
840
+ end
841
+
842
+ module PersistenceObserver
843
+ def touch(*names, **options)
844
+ changed_attributes = upkeep_touch_column_names(names)
845
+
846
+ super.tap do |result|
847
+ if result && changed_attributes.any?
848
+ ChangeLog.record(
849
+ ChangeEvents.active_record_update_columns(
850
+ self,
851
+ changed_attributes: changed_attributes,
852
+ new_values: upkeep_touch_column_values(changed_attributes)
853
+ )
854
+ )
855
+ end
856
+ end
857
+ end
858
+
859
+ def update_columns(attributes)
860
+ new_values = upkeep_update_column_values(attributes)
861
+ changed_attributes = new_values.keys
862
+
863
+ super.tap do |result|
864
+ if result
865
+ ChangeLog.record(
866
+ ChangeEvents.active_record_update_columns(
867
+ self,
868
+ changed_attributes: changed_attributes,
869
+ new_values: new_values
870
+ )
871
+ )
872
+ end
873
+ end
874
+ end
875
+
876
+ private
877
+
878
+ def upkeep_touch_column_names(names)
879
+ (self.class.timestamp_attributes_for_update_in_model + Array(names)).filter_map do |attribute|
880
+ attribute = self.class.attribute_aliases.fetch(attribute.to_s, attribute.to_s)
881
+ attribute if self.class.column_names.include?(attribute)
882
+ end.uniq
883
+ end
884
+
885
+ def upkeep_touch_column_values(attributes)
886
+ attributes.to_h { |attribute| [attribute, public_send(attribute)] }
887
+ end
888
+
889
+ def upkeep_update_column_values(attributes)
890
+ attributes.to_h.reject { |attribute, _value| attribute.to_s == "touch" }.transform_keys do |attribute|
891
+ self.class.attribute_aliases.fetch(attribute.to_s, attribute.to_s)
892
+ end
893
+ end
894
+ end
895
+
896
+ module RelationObserver
897
+ SUPPRESS_DEPENDENCY_KEY = :upkeep_runtime_relation_dependency_suppressed
898
+
899
+ def self.suppress_dependency_tracking
900
+ previous = Thread.current[SUPPRESS_DEPENDENCY_KEY]
901
+ Thread.current[SUPPRESS_DEPENDENCY_KEY] = true
902
+ yield
903
+ ensure
904
+ Thread.current[SUPPRESS_DEPENDENCY_KEY] = previous
905
+ end
906
+
907
+ def self.dependency_tracking_suppressed?
908
+ Thread.current[SUPPRESS_DEPENDENCY_KEY]
909
+ end
910
+
911
+ def exec_queries(...)
912
+ analysis = relation_analysis_for_observation
913
+ super.tap do |records|
914
+ record_relation_provenance(records, analysis)
915
+ record_relation_dependency(analysis)
916
+ end
917
+ end
918
+
919
+ def to_ary
920
+ analysis = relation_analysis_for_observation
921
+ super.tap do |records|
922
+ record_relation_provenance(records, analysis)
923
+ record_relation_dependency(analysis)
924
+ end
925
+ end
926
+
927
+ def to_a
928
+ to_ary
929
+ end
930
+
931
+ def pluck(*column_names)
932
+ record_query_dependency(column_names)
933
+ super
934
+ end
935
+
936
+ def update_all(updates)
937
+ analysis = ActiveRecordQuery.analyze(self, opaque_table_policy: :allow_table)
938
+ event = ChangeEvents.bulk_update(
939
+ table: klass.table_name,
940
+ model: klass.name,
941
+ changed_attributes: update_columns(updates),
942
+ predicate_sql: analysis.sql,
943
+ predicate_coverage: analysis.coverage.to_s,
944
+ predicate_table_columns: analysis.table_columns,
945
+ new_values: update_values(updates),
946
+ id: single_primary_key_predicate_value(analysis)
947
+ )
948
+
949
+ super.tap { ChangeLog.record(event) }
950
+ end
951
+
952
+ def delete_all
953
+ analysis = ActiveRecordQuery.analyze(self, opaque_table_policy: :allow_table)
954
+ event = ChangeEvents.bulk_delete(
955
+ table: klass.table_name,
956
+ model: klass.name,
957
+ changed_attributes: [klass.primary_key].compact,
958
+ predicate_sql: analysis.sql,
959
+ predicate_coverage: analysis.coverage.to_s,
960
+ predicate_table_columns: analysis.table_columns,
961
+ id: single_primary_key_predicate_value(analysis)
962
+ )
963
+
964
+ super.tap { ChangeLog.record(event) }
965
+ end
966
+
967
+ private
968
+
969
+ def relation_analysis_for_observation
970
+ return unless Observation.recorder
971
+ return if RelationObserver.dependency_tracking_suppressed?
972
+ return @upkeep_relation_analysis if instance_variable_defined?(:@upkeep_relation_analysis)
973
+
974
+ @upkeep_relation_analysis = ActiveRecordQuery.analyze(self)
975
+ rescue ActiveRecordQuery::OpaqueRelationError => error
976
+ @upkeep_relation_analysis = nil
977
+ handle_opaque_relation_dependency(error)
978
+ nil
979
+ end
980
+
981
+ def record_relation_provenance(records, analysis)
982
+ Observation.record_relation_provenance(records, model_name: klass.name, analysis: analysis) if analysis
983
+ end
984
+
985
+ def record_relation_dependency(analysis)
986
+ return unless analysis
987
+ return unless Observation.recorder&.current_frame
988
+
989
+ Observation.record_dependency(
990
+ Dependencies::ActiveRecordQuery.new(
991
+ primary_table: analysis.primary_table,
992
+ table_columns: analysis.table_columns,
993
+ coverage: analysis.coverage,
994
+ sql: analysis.sql,
995
+ predicates: analysis.predicates
996
+ )
997
+ )
998
+ end
999
+
1000
+ def record_query_dependency(column_names)
1001
+ analysis = relation_analysis_for_observation
1002
+ return unless analysis
1003
+
1004
+ Observation.record_dependency(
1005
+ Dependencies::ActiveRecordQuery.new(
1006
+ primary_table: analysis.primary_table,
1007
+ table_columns: relation_table_columns(analysis, pluck_dependency_columns(column_names)),
1008
+ coverage: analysis.coverage,
1009
+ sql: analysis.sql,
1010
+ predicates: analysis.predicates
1011
+ )
1012
+ )
1013
+ rescue ActiveRecordQuery::OpaqueRelationError => error
1014
+ handle_opaque_relation_dependency(error)
1015
+ end
1016
+
1017
+ def relation_table_columns(analysis, extra_columns)
1018
+ analysis.table_columns.merge(
1019
+ klass.table_name => (analysis.table_columns.fetch(klass.table_name, []) + extra_columns).uniq.sort
1020
+ )
1021
+ end
1022
+
1023
+ def pluck_dependency_columns(column_names)
1024
+ column_names.flatten.map do |column_name|
1025
+ pluck_dependency_column(column_name)
1026
+ end
1027
+ end
1028
+
1029
+ def pluck_dependency_column(column_name)
1030
+ case column_name
1031
+ when Symbol
1032
+ return column_name.to_s
1033
+ when String
1034
+ return column_name if klass.column_names.include?(column_name)
1035
+ else
1036
+ if column_name.respond_to?(:to_sym)
1037
+ name = column_name.to_sym.to_s
1038
+ return name if klass.column_names.include?(name)
1039
+ end
1040
+
1041
+ if column_name.respond_to?(:name) && column_name.respond_to?(:relation)
1042
+ name = column_name.name.to_s
1043
+ return name if klass.column_names.include?(name)
1044
+ end
1045
+ end
1046
+
1047
+ raise ActiveRecordQuery::OpaqueRelationError.new(
1048
+ self,
1049
+ reasons: ["opaque pluck column #{column_name.inspect}"]
1050
+ )
1051
+ end
1052
+
1053
+ def handle_opaque_relation_dependency(error)
1054
+ raise error if refused_boundary_behavior == :raise
1055
+
1056
+ payload = {
1057
+ reason: "opaque_active_record_relation",
1058
+ message: error.message,
1059
+ suggestions: error.suggestions,
1060
+ source: "active_record_relation"
1061
+ }
1062
+
1063
+ if Observation.refuse_boundary(payload)
1064
+ ActiveSupport::Notifications.instrument("refused_boundary.upkeep", payload)
1065
+ warn_refused_boundary(payload)
1066
+ end
1067
+ end
1068
+
1069
+ def refused_boundary_behavior
1070
+ if defined?(Upkeep::Rails) && Upkeep::Rails.respond_to?(:configuration)
1071
+ Upkeep::Rails.configuration.refused_boundary_behavior
1072
+ else
1073
+ :raise
1074
+ end
1075
+ end
1076
+
1077
+ def warn_refused_boundary(payload)
1078
+ return unless defined?(::Rails) && ::Rails.respond_to?(:logger) && ::Rails.logger
1079
+
1080
+ ::Rails.logger.warn(
1081
+ "Upkeep refused #{payload.fetch(:source)}: #{payload.fetch(:reason)}. " \
1082
+ "#{payload.fetch(:suggestions).join(" ")}"
1083
+ )
1084
+ end
1085
+
1086
+ def update_columns(updates)
1087
+ case updates
1088
+ when Hash
1089
+ updates.keys.map(&:to_s)
1090
+ else
1091
+ klass.column_names
1092
+ end
1093
+ end
1094
+
1095
+ def update_values(updates)
1096
+ return {} unless updates.is_a?(Hash)
1097
+
1098
+ updates.transform_keys(&:to_s)
1099
+ end
1100
+
1101
+ def single_primary_key_predicate_value(analysis)
1102
+ primary_key = analysis.primary_key
1103
+ return unless primary_key
1104
+
1105
+ predicates = analysis.predicates.select do |predicate|
1106
+ predicate.fetch(:table) == analysis.primary_table.to_s &&
1107
+ predicate.fetch(:column) == primary_key.to_s &&
1108
+ %w[eq in].include?(predicate.fetch(:operator).to_s)
1109
+ end
1110
+ return unless predicates.size == 1
1111
+
1112
+ values = predicates.first.fetch(:values)
1113
+ values.first if values.size == 1
1114
+ end
1115
+ end
1116
+
1117
+ module Install
1118
+ module_function
1119
+
1120
+ def call
1121
+ return if @installed
1122
+
1123
+ install_current_attributes_observer
1124
+ install_warden_observer
1125
+ install_action_dispatch_observers
1126
+
1127
+ ActiveRecord::AttributeMethods::Read.prepend(AttributeObserver)
1128
+ ActiveRecord::Base.prepend(PersistenceObserver) unless ActiveRecord::Base < PersistenceObserver
1129
+ ActiveRecord::Base.prepend(CacheKeyObserver) unless ActiveRecord::Base < CacheKeyObserver
1130
+ ActiveRecord::Relation.prepend(RelationObserver)
1131
+
1132
+ ActiveRecord::Base.after_commit do |record|
1133
+ ChangeLog.record(ChangeEvents.active_record_commit(record))
1134
+ end
1135
+
1136
+ @installed = true
1137
+ end
1138
+
1139
+ def install_current_attributes_observer
1140
+ singleton = class << ActiveSupport::CurrentAttributes; self; end
1141
+ singleton.prepend(CurrentAttributesClassObserver) unless singleton < CurrentAttributesClassObserver
1142
+
1143
+ ObjectSpace.each_object(Class) do |klass|
1144
+ next unless klass < ActiveSupport::CurrentAttributes
1145
+
1146
+ Runtime.wrap_current_attribute_readers(klass, current_attribute_names(klass))
1147
+ end
1148
+ end
1149
+
1150
+ def install_warden_observer
1151
+ return unless defined?(::Warden::Proxy)
1152
+ return if ::Warden::Proxy < WardenObserver
1153
+
1154
+ ::Warden::Proxy.prepend(WardenObserver)
1155
+ end
1156
+
1157
+ def install_action_dispatch_observers
1158
+ if defined?(::ActionDispatch::Request::Session) && !(::ActionDispatch::Request::Session < SessionObserver)
1159
+ ::ActionDispatch::Request::Session.prepend(SessionObserver)
1160
+ end
1161
+
1162
+ if defined?(::ActionDispatch::Cookies::CookieJar) && !(::ActionDispatch::Cookies::CookieJar < CookieObserver)
1163
+ ::ActionDispatch::Cookies::CookieJar.prepend(CookieObserver)
1164
+ end
1165
+
1166
+ if defined?(::ActionDispatch::Request) && !(::ActionDispatch::Request < RequestObserver)
1167
+ ::ActionDispatch::Request.prepend(RequestObserver)
1168
+ end
1169
+ end
1170
+
1171
+ def current_attribute_names(klass)
1172
+ if klass.respond_to?(:defaults)
1173
+ klass.defaults.keys
1174
+ else
1175
+ []
1176
+ end
1177
+ end
1178
+ end
1179
+
1180
+ module_function
1181
+
1182
+ def wrap_current_attribute_readers(klass, names)
1183
+ wrapped = klass.instance_variable_get(:@upkeep_wrapped_current_attributes) || {}
1184
+
1185
+ names.each do |name|
1186
+ name = name.to_sym
1187
+ next if wrapped[name]
1188
+ next unless klass.method_defined?(name)
1189
+
1190
+ original_reader = klass.instance_method(name)
1191
+ klass.define_method(name) do
1192
+ value = original_reader.bind_call(self)
1193
+ Runtime::Ambient.record_current_attribute(klass.name || klass.inspect, name, value)
1194
+ value
1195
+ end
1196
+ wrapped[name] = true
1197
+ end
1198
+
1199
+ klass.instance_variable_set(:@upkeep_wrapped_current_attributes, wrapped)
1200
+ end
1201
+ end
1202
+ end