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,301 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Upkeep
4
+ module Subscriptions
5
+ class ReverseIndex
6
+ COHORT_IDENTITY_SOURCES = %w[Current.user cookie current_attribute session warden_user].freeze
7
+
8
+ Entry = Data.define(:subscription_id, :owner_id, :dependency_cache_key, :dependency, :subscriber_ids, :cohort_key) do
9
+ def represented_subscriber_ids
10
+ Array(subscriber_ids)
11
+ end
12
+
13
+ def cohort?
14
+ !!cohort_key
15
+ end
16
+ end
17
+
18
+ def initialize
19
+ @entries_by_lookup_key = Hash.new { |hash, key| hash[key] = [] }
20
+ @entry_keys_by_lookup_key = Hash.new { |hash, key| hash[key] = {} }
21
+ @lookup_keys_by_subscription_id = Hash.new { |hash, key| hash[key] = {} }
22
+ @cohort_entries_by_index_key = {}
23
+ @cohort_members_by_index_key = Hash.new { |hash, key| hash[key] = {} }
24
+ @cohort_index_keys_by_subscription_id = Hash.new { |hash, key| hash[key] = {} }
25
+ end
26
+
27
+ def index(subscription)
28
+ index_entries(entries_for_subscription(subscription))
29
+ end
30
+
31
+ def index_entries(entries, subscription: nil)
32
+ entries = entries_for_subscription_instance(entries, subscription) if subscription
33
+
34
+ entries.each do |entry|
35
+ lookup_keys_for_dependency(entry.dependency).each do |lookup_key|
36
+ if entry.cohort?
37
+ index_cohort_entry(lookup_key, entry)
38
+ else
39
+ index_direct_entry(lookup_key, entry)
40
+ end
41
+ end
42
+ end
43
+ end
44
+
45
+ def delete_subscription(subscription_id)
46
+ lookup_keys = @lookup_keys_by_subscription_id.delete(subscription_id)&.keys || []
47
+ lookup_keys.each do |lookup_key|
48
+ entries = @entries_by_lookup_key.fetch(lookup_key, nil)
49
+ next unless entries
50
+
51
+ entries.reject! { |entry| entry.subscription_id == subscription_id }
52
+ @entry_keys_by_lookup_key[lookup_key].delete_if { |entry_key, _present| entry_key.fetch(0) == subscription_id }
53
+
54
+ next unless entries.empty?
55
+
56
+ @entries_by_lookup_key.delete(lookup_key)
57
+ @entry_keys_by_lookup_key.delete(lookup_key)
58
+ end
59
+
60
+ cohort_index_keys = @cohort_index_keys_by_subscription_id.delete(subscription_id)&.keys || []
61
+ cohort_index_keys.each { |index_key| delete_cohort_subscription(index_key, subscription_id) }
62
+ end
63
+
64
+ def entries_for(changes)
65
+ raw_entries = changes
66
+ .flat_map { |change| lookup_keys_for_change(change) }
67
+ .flat_map { |lookup_key| @entries_by_lookup_key.fetch(lookup_key, []) }
68
+ .uniq { |entry| [entry.subscription_id, entry.owner_id, entry.dependency_cache_key] }
69
+
70
+ collapse_cohort_entries(raw_entries)
71
+ end
72
+
73
+ def summary
74
+ entries = @entries_by_lookup_key.values.flatten
75
+ cohort_entries = entries.select(&:cohort?)
76
+ {
77
+ lookup_keys: @entries_by_lookup_key.size,
78
+ entries: entries.size,
79
+ cohort_entries: cohort_entries.size,
80
+ represented_subscribers: entries.flat_map(&:represented_subscriber_ids).uniq.size
81
+ }
82
+ end
83
+
84
+ def entries_for_subscription(subscription)
85
+ subscription.recorder.flush_pending_dependencies if subscription.recorder.respond_to?(:flush_pending_dependencies)
86
+ cohort_key = cohort_key_for(subscription)
87
+ subscription.graph.dependency_nodes.flat_map do |node|
88
+ subscription.graph.dependency_owner_ids(node.id).map do |owner_id|
89
+ Entry.new(
90
+ subscription.id,
91
+ owner_id,
92
+ node.payload.cache_key,
93
+ node.payload,
94
+ [subscription.subscriber_id],
95
+ cohort_key
96
+ )
97
+ end
98
+ end
99
+ end
100
+
101
+ def entries_for_subscription_instance(entries, subscription)
102
+ entries.map { |entry| entry_for_subscription(entry, subscription) }
103
+ end
104
+
105
+ def lookup_keys_for_dependency(dependency)
106
+ case dependency.source
107
+ when :active_record_attribute
108
+ active_record_attribute_lookup_keys(dependency.key)
109
+ when :active_record_collection, :active_record_query
110
+ active_record_collection_lookup_keys(dependency)
111
+ else
112
+ []
113
+ end
114
+ end
115
+
116
+ def lookup_keys_for_change(change)
117
+ table = change.fetch(:table)
118
+ attributes = change.fetch(:changed_attributes, [])
119
+
120
+ keys = attributes.flat_map do |attribute|
121
+ if change[:id]
122
+ [
123
+ [:active_record_attribute, table, change.fetch(:id), attribute],
124
+ [:active_record_attribute_any_id, table, attribute]
125
+ ]
126
+ else
127
+ [[:active_record_attribute_any_id, table, attribute]]
128
+ end
129
+ end
130
+
131
+ keys.concat(attributes.map { |attribute| [:active_record_collection_column, table, attribute] })
132
+ keys.uniq
133
+ end
134
+
135
+ private
136
+
137
+ def index_direct_entry(lookup_key, entry)
138
+ entry_key = [entry.subscription_id, entry.owner_id, entry.dependency_cache_key]
139
+ entry_keys = @entry_keys_by_lookup_key[lookup_key]
140
+ return if entry_keys.key?(entry_key)
141
+
142
+ @entries_by_lookup_key[lookup_key] << entry
143
+ entry_keys[entry_key] = true
144
+ @lookup_keys_by_subscription_id[entry.subscription_id][lookup_key] = true
145
+ end
146
+
147
+ def index_cohort_entry(lookup_key, entry)
148
+ index_key = cohort_index_key(lookup_key, entry)
149
+ members = @cohort_members_by_index_key[index_key]
150
+ subscriber_ids = (members[entry.subscription_id] || []) | entry.represented_subscriber_ids
151
+ return if members[entry.subscription_id] == subscriber_ids
152
+
153
+ existing_entry = @cohort_entries_by_index_key[index_key]
154
+ members[entry.subscription_id] = subscriber_ids
155
+ replacement_entry = existing_entry ? append_cohort_members(existing_entry, subscriber_ids) : cohort_entry_from(entry, members)
156
+
157
+ if existing_entry
158
+ replace_entry(lookup_key, existing_entry, replacement_entry)
159
+ else
160
+ @entries_by_lookup_key[lookup_key] << replacement_entry
161
+ end
162
+
163
+ @cohort_entries_by_index_key[index_key] = replacement_entry
164
+ @cohort_index_keys_by_subscription_id[entry.subscription_id][index_key] = true
165
+ end
166
+
167
+ def delete_cohort_subscription(index_key, subscription_id)
168
+ lookup_key = index_key.fetch(0)
169
+ members = @cohort_members_by_index_key.fetch(index_key, nil)
170
+ return unless members&.key?(subscription_id)
171
+
172
+ existing_entry = @cohort_entries_by_index_key.fetch(index_key)
173
+ members.delete(subscription_id)
174
+
175
+ if members.empty?
176
+ remove_entry(lookup_key, existing_entry)
177
+ @cohort_members_by_index_key.delete(index_key)
178
+ @cohort_entries_by_index_key.delete(index_key)
179
+ else
180
+ replacement_entry = cohort_entry_from(existing_entry, members)
181
+ replace_entry(lookup_key, existing_entry, replacement_entry)
182
+ @cohort_entries_by_index_key[index_key] = replacement_entry
183
+ end
184
+ end
185
+
186
+ def cohort_index_key(lookup_key, entry)
187
+ [lookup_key, entry.cohort_key, entry.owner_id, entry.dependency_cache_key]
188
+ end
189
+
190
+ def cohort_entry_from(entry, members)
191
+ Entry.new(
192
+ members.keys.sort_by(&:to_s).first,
193
+ entry.owner_id,
194
+ entry.dependency_cache_key,
195
+ entry.dependency,
196
+ members.values.flatten.uniq.sort_by(&:to_s),
197
+ entry.cohort_key
198
+ )
199
+ end
200
+
201
+ def append_cohort_members(entry, subscriber_ids)
202
+ Entry.new(
203
+ entry.subscription_id,
204
+ entry.owner_id,
205
+ entry.dependency_cache_key,
206
+ entry.dependency,
207
+ entry.represented_subscriber_ids | subscriber_ids,
208
+ entry.cohort_key
209
+ )
210
+ end
211
+
212
+ def replace_entry(lookup_key, existing_entry, replacement_entry)
213
+ entries = @entries_by_lookup_key.fetch(lookup_key)
214
+ index = entries.index(existing_entry)
215
+ entries[index] = replacement_entry if index
216
+ end
217
+
218
+ def remove_entry(lookup_key, entry)
219
+ entries = @entries_by_lookup_key.fetch(lookup_key, nil)
220
+ return unless entries
221
+
222
+ entries.delete(entry)
223
+
224
+ return unless entries.empty?
225
+
226
+ @entries_by_lookup_key.delete(lookup_key)
227
+ @entry_keys_by_lookup_key.delete(lookup_key)
228
+ end
229
+
230
+ def entry_for_subscription(entry, subscription)
231
+ Entry.new(
232
+ entry.subscription_id || subscription.id,
233
+ entry.owner_id,
234
+ entry.dependency_cache_key,
235
+ entry.dependency,
236
+ entry.subscriber_ids || [subscription.subscriber_id],
237
+ entry.cohort_key || cohort_key_for(subscription)
238
+ )
239
+ end
240
+
241
+ def collapse_cohort_entries(entries)
242
+ direct_entries = []
243
+ cohort_entries = Hash.new { |hash, key| hash[key] = [] }
244
+
245
+ entries.each do |entry|
246
+ if entry.cohort?
247
+ cohort_entries[[entry.cohort_key, entry.owner_id, entry.dependency_cache_key]] << entry
248
+ else
249
+ direct_entries << entry
250
+ end
251
+ end
252
+
253
+ direct_entries + cohort_entries.values.map { |group| collapse_cohort_entry_group(group) }
254
+ end
255
+
256
+ def collapse_cohort_entry_group(entries)
257
+ representative = entries.first
258
+ Entry.new(
259
+ representative.subscription_id,
260
+ representative.owner_id,
261
+ representative.dependency_cache_key,
262
+ representative.dependency,
263
+ entries.flat_map(&:represented_subscriber_ids).uniq.sort_by(&:to_s),
264
+ representative.cohort_key
265
+ )
266
+ end
267
+
268
+ def cohort_key_for(subscription)
269
+ return unless identity_free_subscription?(subscription)
270
+
271
+ metadata_value(subscription, :subscription_shape_key)
272
+ end
273
+
274
+ def identity_free_subscription?(subscription)
275
+ return false if subscription.graph.dependency_nodes.any? { |node| cohort_identity_dependency?(node.payload) }
276
+
277
+ true
278
+ end
279
+
280
+ def metadata_value(subscription, key)
281
+ subscription.metadata[key] || subscription.metadata[key.to_s]
282
+ end
283
+
284
+ def cohort_identity_dependency?(dependency)
285
+ Dependencies.partitioning_identity?(dependency) && COHORT_IDENTITY_SOURCES.include?(dependency.source.to_s)
286
+ end
287
+
288
+ def active_record_collection_lookup_keys(dependency)
289
+ dependency.collection_lookup_columns.map { |table, column| [:active_record_collection_column, table, column] }
290
+ end
291
+
292
+ def active_record_attribute_lookup_keys(key)
293
+ if key.fetch(:id)
294
+ [[:active_record_attribute, key.fetch(:table), key.fetch(:id), key.fetch(:attribute)]]
295
+ else
296
+ [[:active_record_attribute_any_id, key.fetch(:table), key.fetch(:attribute)]]
297
+ end
298
+ end
299
+ end
300
+ end
301
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/notifications"
4
+ require_relative "../shared_streams"
5
+ require_relative "reverse_index"
6
+
7
+ module Upkeep
8
+ module Subscriptions
9
+ class ShapeCache
10
+ NOTIFICATION = "subscription_shape.upkeep"
11
+
12
+ Shape = Data.define(:key, :entries, :shared_stream_names)
13
+ Result = Data.define(:key, :entries, :shared_stream_names, :cache_state, :cacheable, :reason, :timings)
14
+ TemplateSubscription = Data.define(:id, :subscriber_id, :recorder, :graph, :metadata)
15
+
16
+ def initialize(index_builder: ReverseIndex.new)
17
+ @index_builder = index_builder
18
+ @shapes = {}
19
+ @mutex = Mutex.new
20
+ end
21
+
22
+ def resolve(recorder:, decision:, signature: nil)
23
+ if ActiveSupport::Notifications.notifier.listening?(NOTIFICATION)
24
+ payload = {}
25
+ ActiveSupport::Notifications.instrument(NOTIFICATION, payload) do
26
+ result = resolve_without_instrumentation(recorder: recorder, decision: decision, signature: signature)
27
+ payload.merge!(
28
+ key: result.key,
29
+ cache_state: result.cache_state,
30
+ cacheable: result.cacheable,
31
+ reason: result.reason,
32
+ entries: result.entries.size,
33
+ shared_stream_names: result.shared_stream_names.size
34
+ )
35
+ payload.merge!(result.timings)
36
+ result
37
+ end
38
+ else
39
+ resolve_without_instrumentation(recorder: recorder, decision: decision, signature: signature)
40
+ end
41
+ end
42
+
43
+ def reset
44
+ @mutex.synchronize { @shapes = {} }
45
+ end
46
+
47
+ private
48
+
49
+ attr_reader :index_builder
50
+
51
+ def resolve_without_instrumentation(recorder:, decision:, signature:)
52
+ timings = {}
53
+ unless cacheable?(recorder, decision)
54
+ shape = measure(timings, :compile_ms) { compile_shape(recorder, key: nil, timings: timings) }
55
+ return Result.new(nil, shape.entries, shape.shared_stream_names, "uncached", false, cache_bypass_reason(recorder, decision), timings)
56
+ end
57
+
58
+ key = measure(timings, :key_ms) { shape_key_for(recorder, signature: signature) }
59
+ @mutex.synchronize do
60
+ if (shape = @shapes[key])
61
+ Result.new(key, shape.entries, shape.shared_stream_names, "hit", true, nil, timings)
62
+ else
63
+ shape = measure(timings, :compile_ms) { compile_shape(recorder, key: key, timings: timings) }
64
+ @shapes[key] = shape
65
+ Result.new(key, shape.entries, shape.shared_stream_names, "miss", true, nil, timings)
66
+ end
67
+ end
68
+ end
69
+
70
+ def cacheable?(recorder, decision)
71
+ decision&.anonymous && recorder.reactive?
72
+ end
73
+
74
+ def cache_bypass_reason(recorder, decision)
75
+ return "identified" unless decision&.anonymous
76
+ return "refused_boundary" unless recorder.reactive?
77
+
78
+ "uncacheable"
79
+ end
80
+
81
+ def compile_shape(recorder, key:, timings:)
82
+ subscription = TemplateSubscription.new(nil, nil, recorder, recorder.graph, { subscription_shape_key: key }.compact)
83
+ entries = measure(timings, :index_template_ms) do
84
+ index_builder.entries_for_subscription(subscription)
85
+ end
86
+ .map { |entry| template_entry(entry) }
87
+ .uniq { |entry| [entry.owner_id, entry.dependency_cache_key] }
88
+ .freeze
89
+ shared_stream_names = measure(timings, :shared_stream_names_ms) { SharedStreams.names_for_recorder(recorder) }.freeze
90
+ Shape.new(key, entries, shared_stream_names)
91
+ end
92
+
93
+ def template_entry(entry)
94
+ ReverseIndex::Entry.new(
95
+ nil,
96
+ entry.owner_id,
97
+ entry.dependency_cache_key,
98
+ entry.dependency,
99
+ nil,
100
+ entry.cohort_key
101
+ )
102
+ end
103
+
104
+ def shape_key_for(recorder, signature:)
105
+ recorder.subscription_shape(request_signature: signature).signature
106
+ end
107
+
108
+ def measure(timings, key)
109
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
110
+ yield
111
+ ensure
112
+ timings[key] = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000.0).round(3)
113
+ end
114
+ end
115
+ end
116
+ end