upkeep-rails 0.2.5-aarch64-linux-gnu

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 (81) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +244 -0
  4. data/docs/drafts/turbo-streams-is-cache-invalidation-you-write-by-hand.md +240 -0
  5. data/docs/how-it-works.md +329 -0
  6. data/docs/plans/sqlglot-active-record-migration.md +211 -0
  7. data/docs/plans/turbo-frame-subscription-composition.md +90 -0
  8. data/docs/spikes/SQLGLOT_VS_AREL_FINDINGS.md +136 -0
  9. data/docs/spikes/query-source-analysis-comparison/FINDINGS.md +169 -0
  10. data/docs/spikes/sqlglot-first-active-record/README.md +26 -0
  11. data/docs/spikes/sqlglot-query-analysis/FINDINGS.md +110 -0
  12. data/docs/spikes/sqlglot-query-analysis/PULSE_1802_FINDINGS.md +95 -0
  13. data/docs/spikes/sqlglot-semantic-bindings/README.md +29 -0
  14. data/lib/generators/upkeep/install/install_generator.rb +192 -0
  15. data/lib/generators/upkeep/install/templates/create_upkeep_subscriptions.rb.erb +49 -0
  16. data/lib/generators/upkeep/install/templates/subscription.js +288 -0
  17. data/lib/generators/upkeep/install/templates/upkeep.rb +65 -0
  18. data/lib/upkeep/active_record_query.rb +248 -0
  19. data/lib/upkeep/capture/request.rb +150 -0
  20. data/lib/upkeep/dag/subscription_shape.rb +244 -0
  21. data/lib/upkeep/dag.rb +454 -0
  22. data/lib/upkeep/delivery/action_cable_adapter.rb +48 -0
  23. data/lib/upkeep/delivery/async_dispatcher.rb +102 -0
  24. data/lib/upkeep/delivery/broadcast_transport.rb +89 -0
  25. data/lib/upkeep/delivery/transport.rb +194 -0
  26. data/lib/upkeep/delivery/turbo_streams.rb +339 -0
  27. data/lib/upkeep/delivery.rb +7 -0
  28. data/lib/upkeep/dependencies.rb +600 -0
  29. data/lib/upkeep/herb/developer_report.rb +135 -0
  30. data/lib/upkeep/herb/manifest_cache.rb +83 -0
  31. data/lib/upkeep/herb/manifest_diff.rb +183 -0
  32. data/lib/upkeep/herb/source_instrumenter.rb +149 -0
  33. data/lib/upkeep/herb/template_manifest.rb +548 -0
  34. data/lib/upkeep/invalidation/collection_append.rb +84 -0
  35. data/lib/upkeep/invalidation/collection_member_replace.rb +78 -0
  36. data/lib/upkeep/invalidation/collection_prepend.rb +84 -0
  37. data/lib/upkeep/invalidation/collection_remove.rb +57 -0
  38. data/lib/upkeep/invalidation/planner.rb +411 -0
  39. data/lib/upkeep/invalidation.rb +7 -0
  40. data/lib/upkeep/rails/action_view_capture.rb +1007 -0
  41. data/lib/upkeep/rails/activation_token.rb +55 -0
  42. data/lib/upkeep/rails/cable/channel.rb +165 -0
  43. data/lib/upkeep/rails/cable/subscriber_identity.rb +361 -0
  44. data/lib/upkeep/rails/cable.rb +4 -0
  45. data/lib/upkeep/rails/client_subscription.rb +65 -0
  46. data/lib/upkeep/rails/cluster_guard.rb +57 -0
  47. data/lib/upkeep/rails/configuration.rb +252 -0
  48. data/lib/upkeep/rails/controller_runtime.rb +187 -0
  49. data/lib/upkeep/rails/install.rb +28 -0
  50. data/lib/upkeep/rails/job_runtime.rb +43 -0
  51. data/lib/upkeep/rails/railtie.rb +44 -0
  52. data/lib/upkeep/rails/replay.rb +244 -0
  53. data/lib/upkeep/rails/testing.rb +259 -0
  54. data/lib/upkeep/rails.rb +466 -0
  55. data/lib/upkeep/replay.rb +462 -0
  56. data/lib/upkeep/runtime.rb +1276 -0
  57. data/lib/upkeep/shared_streams.rb +86 -0
  58. data/lib/upkeep/sql_dependency_analysis.rb +553 -0
  59. data/lib/upkeep/sqlglot/libsqlglot_rust.so +0 -0
  60. data/lib/upkeep/sqlglot/native.rb +121 -0
  61. data/lib/upkeep/sqlglot/native_library.rb +23 -0
  62. data/lib/upkeep/sqlglot.rb +367 -0
  63. data/lib/upkeep/subscriptions/active_record_store.rb +398 -0
  64. data/lib/upkeep/subscriptions/active_record_subscription_persistence.rb +411 -0
  65. data/lib/upkeep/subscriptions/active_registry.rb +80 -0
  66. data/lib/upkeep/subscriptions/base_store.rb +110 -0
  67. data/lib/upkeep/subscriptions/json_snapshot.rb +98 -0
  68. data/lib/upkeep/subscriptions/layered_reverse_index.rb +125 -0
  69. data/lib/upkeep/subscriptions/lookup_instrumentation.rb +32 -0
  70. data/lib/upkeep/subscriptions/persistent_reverse_index.rb +228 -0
  71. data/lib/upkeep/subscriptions/registrar.rb +36 -0
  72. data/lib/upkeep/subscriptions/reverse_index.rb +313 -0
  73. data/lib/upkeep/subscriptions/shape.rb +117 -0
  74. data/lib/upkeep/subscriptions/store.rb +349 -0
  75. data/lib/upkeep/subscriptions.rb +7 -0
  76. data/lib/upkeep/targeting.rb +146 -0
  77. data/lib/upkeep/version.rb +5 -0
  78. data/lib/upkeep-rails.rb +3 -0
  79. data/lib/upkeep.rb +15 -0
  80. data/upkeep-rails.gemspec +66 -0
  81. metadata +327 -0
@@ -0,0 +1,313 @@
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, :recipients) do
9
+ def represented_subscriber_ids
10
+ ids = Array(subscriber_ids)
11
+ return ids unless ids.empty?
12
+
13
+ represented_recipients.map(&:last).uniq
14
+ end
15
+
16
+ def represented_recipients
17
+ Array(recipients)
18
+ end
19
+
20
+ def cohort?
21
+ !!cohort_key
22
+ end
23
+ end
24
+
25
+ def initialize
26
+ @entries_by_lookup_key = Hash.new { |hash, key| hash[key] = [] }
27
+ @entry_keys_by_lookup_key = Hash.new { |hash, key| hash[key] = {} }
28
+ @lookup_keys_by_subscription_id = Hash.new { |hash, key| hash[key] = {} }
29
+ @cohort_entries_by_index_key = {}
30
+ @cohort_members_by_index_key = Hash.new { |hash, key| hash[key] = {} }
31
+ @cohort_index_keys_by_subscription_id = Hash.new { |hash, key| hash[key] = {} }
32
+ end
33
+
34
+ def index(subscription)
35
+ index_entries(entries_for_subscription(subscription))
36
+ end
37
+
38
+ def index_entries(entries, subscription: nil)
39
+ entries = entries_for_subscription_instance(entries, subscription) if subscription
40
+
41
+ entries.each do |entry|
42
+ lookup_keys_for_dependency(entry.dependency).each do |lookup_key|
43
+ if entry.cohort?
44
+ index_cohort_entry(lookup_key, entry)
45
+ else
46
+ index_direct_entry(lookup_key, entry)
47
+ end
48
+ end
49
+ end
50
+ end
51
+
52
+ def delete_subscription(subscription_id)
53
+ lookup_keys = @lookup_keys_by_subscription_id.delete(subscription_id)&.keys || []
54
+ lookup_keys.each do |lookup_key|
55
+ entries = @entries_by_lookup_key.fetch(lookup_key, nil)
56
+ next unless entries
57
+
58
+ entries.reject! { |entry| entry.subscription_id == subscription_id }
59
+ @entry_keys_by_lookup_key[lookup_key].delete_if { |entry_key, _present| entry_key.fetch(0) == subscription_id }
60
+
61
+ next unless entries.empty?
62
+
63
+ @entries_by_lookup_key.delete(lookup_key)
64
+ @entry_keys_by_lookup_key.delete(lookup_key)
65
+ end
66
+
67
+ cohort_index_keys = @cohort_index_keys_by_subscription_id.delete(subscription_id)&.keys || []
68
+ cohort_index_keys.each { |index_key| delete_cohort_subscription(index_key, subscription_id) }
69
+ end
70
+
71
+ def entries_for(changes)
72
+ raw_entries = changes
73
+ .flat_map { |change| lookup_keys_for_change(change) }
74
+ .flat_map { |lookup_key| @entries_by_lookup_key.fetch(lookup_key, []) }
75
+ .uniq { |entry| [entry.subscription_id, entry.owner_id, entry.dependency_cache_key] }
76
+
77
+ collapse_cohort_entries(raw_entries)
78
+ end
79
+
80
+ def summary
81
+ entries = @entries_by_lookup_key.values.flatten
82
+ cohort_entries = entries.select(&:cohort?)
83
+ {
84
+ lookup_keys: @entries_by_lookup_key.size,
85
+ entries: entries.size,
86
+ cohort_entries: cohort_entries.size,
87
+ represented_subscribers: entries.flat_map(&:represented_subscriber_ids).uniq.size
88
+ }
89
+ end
90
+
91
+ def entries_for_subscription(subscription)
92
+ subscription.recorder.flush_pending_dependencies if subscription.recorder.respond_to?(:flush_pending_dependencies)
93
+ cohort_key = cohort_key_for(subscription)
94
+ subscription.graph.dependency_nodes.flat_map do |node|
95
+ subscription.graph.dependency_owner_ids(node.id).map do |owner_id|
96
+ Entry.new(
97
+ subscription.id,
98
+ owner_id,
99
+ node.payload.cache_key,
100
+ node.payload,
101
+ [subscription.subscriber_id],
102
+ cohort_key,
103
+ [[subscription.id, subscription.subscriber_id]]
104
+ )
105
+ end
106
+ end
107
+ end
108
+
109
+ def entries_for_subscription_instance(entries, subscription)
110
+ entries.map { |entry| entry_for_subscription(entry, subscription) }
111
+ end
112
+
113
+ def lookup_keys_for_dependency(dependency)
114
+ case dependency.source
115
+ when :active_record_attribute
116
+ active_record_attribute_lookup_keys(dependency.key)
117
+ when :active_record_collection, :active_record_query
118
+ active_record_collection_lookup_keys(dependency)
119
+ else
120
+ []
121
+ end
122
+ end
123
+
124
+ def lookup_keys_for_change(change)
125
+ table = change.fetch(:table)
126
+ attributes = change.fetch(:changed_attributes, [])
127
+
128
+ keys = attributes.flat_map do |attribute|
129
+ if change[:id]
130
+ [
131
+ [:active_record_attribute, table, change.fetch(:id), attribute],
132
+ [:active_record_attribute_any_id, table, attribute]
133
+ ]
134
+ else
135
+ [[:active_record_attribute_any_id, table, attribute]]
136
+ end
137
+ end
138
+
139
+ keys.concat(attributes.map { |attribute| [:active_record_collection_column, table, attribute] })
140
+ keys.uniq
141
+ end
142
+
143
+ private
144
+
145
+ def index_direct_entry(lookup_key, entry)
146
+ entry_key = [entry.subscription_id, entry.owner_id, entry.dependency_cache_key]
147
+ entry_keys = @entry_keys_by_lookup_key[lookup_key]
148
+ return if entry_keys.key?(entry_key)
149
+
150
+ @entries_by_lookup_key[lookup_key] << entry
151
+ entry_keys[entry_key] = true
152
+ @lookup_keys_by_subscription_id[entry.subscription_id][lookup_key] = true
153
+ end
154
+
155
+ def index_cohort_entry(lookup_key, entry)
156
+ index_key = cohort_index_key(lookup_key, entry)
157
+ members = @cohort_members_by_index_key[index_key]
158
+ subscriber_ids = (members[entry.subscription_id] || []) | entry.represented_subscriber_ids
159
+ return if members[entry.subscription_id] == subscriber_ids
160
+
161
+ existing_entry = @cohort_entries_by_index_key[index_key]
162
+ members[entry.subscription_id] = subscriber_ids
163
+ replacement_entry = existing_entry ? append_cohort_members(existing_entry, entry.subscription_id, subscriber_ids) : cohort_entry_from(entry, members)
164
+
165
+ if existing_entry
166
+ replace_entry(lookup_key, existing_entry, replacement_entry)
167
+ else
168
+ @entries_by_lookup_key[lookup_key] << replacement_entry
169
+ end
170
+
171
+ @cohort_entries_by_index_key[index_key] = replacement_entry
172
+ @cohort_index_keys_by_subscription_id[entry.subscription_id][index_key] = true
173
+ end
174
+
175
+ def delete_cohort_subscription(index_key, subscription_id)
176
+ lookup_key = index_key.fetch(0)
177
+ members = @cohort_members_by_index_key.fetch(index_key, nil)
178
+ return unless members&.key?(subscription_id)
179
+
180
+ existing_entry = @cohort_entries_by_index_key.fetch(index_key)
181
+ members.delete(subscription_id)
182
+
183
+ if members.empty?
184
+ remove_entry(lookup_key, existing_entry)
185
+ @cohort_members_by_index_key.delete(index_key)
186
+ @cohort_entries_by_index_key.delete(index_key)
187
+ else
188
+ replacement_entry = cohort_entry_from(existing_entry, members)
189
+ replace_entry(lookup_key, existing_entry, replacement_entry)
190
+ @cohort_entries_by_index_key[index_key] = replacement_entry
191
+ end
192
+ end
193
+
194
+ def cohort_index_key(lookup_key, entry)
195
+ [lookup_key, entry.cohort_key, entry.owner_id, entry.dependency_cache_key]
196
+ end
197
+
198
+ def cohort_entry_from(entry, members)
199
+ Entry.new(
200
+ members.keys.sort_by(&:to_s).first,
201
+ entry.owner_id,
202
+ entry.dependency_cache_key,
203
+ entry.dependency,
204
+ members.values.flatten.uniq.sort_by(&:to_s),
205
+ entry.cohort_key,
206
+ members.flat_map { |subscription_id, ids| ids.map { |subscriber_id| [subscription_id, subscriber_id] } }
207
+ )
208
+ end
209
+
210
+ def append_cohort_members(entry, subscription_id, subscriber_ids)
211
+ Entry.new(
212
+ entry.subscription_id,
213
+ entry.owner_id,
214
+ entry.dependency_cache_key,
215
+ entry.dependency,
216
+ entry.represented_subscriber_ids | subscriber_ids,
217
+ entry.cohort_key,
218
+ (entry.represented_recipients + subscriber_ids.map { |subscriber_id| [subscription_id, subscriber_id] }).uniq
219
+ )
220
+ end
221
+
222
+ def replace_entry(lookup_key, existing_entry, replacement_entry)
223
+ entries = @entries_by_lookup_key.fetch(lookup_key)
224
+ index = entries.index(existing_entry)
225
+ entries[index] = replacement_entry if index
226
+ end
227
+
228
+ def remove_entry(lookup_key, entry)
229
+ entries = @entries_by_lookup_key.fetch(lookup_key, nil)
230
+ return unless entries
231
+
232
+ entries.delete(entry)
233
+
234
+ return unless entries.empty?
235
+
236
+ @entries_by_lookup_key.delete(lookup_key)
237
+ @entry_keys_by_lookup_key.delete(lookup_key)
238
+ end
239
+
240
+ def entry_for_subscription(entry, subscription)
241
+ Entry.new(
242
+ entry.subscription_id || subscription.id,
243
+ entry.owner_id,
244
+ entry.dependency_cache_key,
245
+ entry.dependency,
246
+ entry.subscriber_ids || [subscription.subscriber_id],
247
+ entry.cohort_key || cohort_key_for(subscription),
248
+ entry.recipients || [[subscription.id, subscription.subscriber_id]]
249
+ )
250
+ end
251
+
252
+ def collapse_cohort_entries(entries)
253
+ direct_entries = []
254
+ cohort_entries = Hash.new { |hash, key| hash[key] = [] }
255
+
256
+ entries.each do |entry|
257
+ if entry.cohort?
258
+ cohort_entries[[entry.cohort_key, entry.owner_id, entry.dependency_cache_key]] << entry
259
+ else
260
+ direct_entries << entry
261
+ end
262
+ end
263
+
264
+ direct_entries + cohort_entries.values.map { |group| collapse_cohort_entry_group(group) }
265
+ end
266
+
267
+ def collapse_cohort_entry_group(entries)
268
+ representative = entries.first
269
+ Entry.new(
270
+ representative.subscription_id,
271
+ representative.owner_id,
272
+ representative.dependency_cache_key,
273
+ representative.dependency,
274
+ entries.flat_map(&:represented_subscriber_ids).uniq.sort_by(&:to_s),
275
+ representative.cohort_key,
276
+ entries.flat_map(&:represented_recipients).uniq
277
+ )
278
+ end
279
+
280
+ def cohort_key_for(subscription)
281
+ return unless identity_free_subscription?(subscription)
282
+
283
+ metadata_value(subscription, :subscription_shape_key)
284
+ end
285
+
286
+ def identity_free_subscription?(subscription)
287
+ return false if subscription.graph.dependency_nodes.any? { |node| cohort_identity_dependency?(node.payload) }
288
+
289
+ true
290
+ end
291
+
292
+ def metadata_value(subscription, key)
293
+ subscription.metadata[key] || subscription.metadata[key.to_s]
294
+ end
295
+
296
+ def cohort_identity_dependency?(dependency)
297
+ Dependencies.partitioning_identity?(dependency) && COHORT_IDENTITY_SOURCES.include?(dependency.source.to_s)
298
+ end
299
+
300
+ def active_record_collection_lookup_keys(dependency)
301
+ dependency.collection_lookup_columns.map { |table, column| [:active_record_collection_column, table, column] }
302
+ end
303
+
304
+ def active_record_attribute_lookup_keys(key)
305
+ if key.fetch(:id)
306
+ [[:active_record_attribute, key.fetch(:table), key.fetch(:id), key.fetch(:attribute)]]
307
+ else
308
+ [[:active_record_attribute_any_id, key.fetch(:table), key.fetch(:attribute)]]
309
+ end
310
+ end
311
+ end
312
+ end
313
+ end
@@ -0,0 +1,117 @@
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
+ nil
102
+ )
103
+ end
104
+
105
+ def shape_key_for(recorder, signature:)
106
+ recorder.subscription_shape(request_signature: signature).signature
107
+ end
108
+
109
+ def measure(timings, key)
110
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
111
+ yield
112
+ ensure
113
+ timings[key] = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000.0).round(3)
114
+ end
115
+ end
116
+ end
117
+ end