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,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Upkeep
6
+ module SharedStreams
7
+ PREFIX = "upkeep:shared"
8
+
9
+ module_function
10
+
11
+ def stream_name(target:, identity_signature:, sharing_signature:)
12
+ digest = Digest::SHA256.hexdigest([target.kind, target.id, identity_signature, sharing_signature].inspect)[0, 32]
13
+ "#{PREFIX}:#{digest}"
14
+ end
15
+
16
+ def signature_for(recipe)
17
+ if recipe.instance_variable_defined?(:@upkeep_shared_stream_signature)
18
+ recipe.instance_variable_get(:@upkeep_shared_stream_signature)
19
+ else
20
+ Digest::SHA256.hexdigest(recipe.to_h.inspect).tap do |signature|
21
+ recipe.instance_variable_set(:@upkeep_shared_stream_signature, signature)
22
+ end
23
+ end
24
+ end
25
+
26
+ def names_for_subscription(subscription)
27
+ names_for_graph(subscription.graph)
28
+ end
29
+
30
+ def names_for_recorder(recorder)
31
+ names_for_graph(recorder.graph)
32
+ end
33
+
34
+ def names_for_graph(graph)
35
+ graph.frame_nodes.filter_map do |frame|
36
+ next unless frame.payload.fetch(:kind) == "render_site"
37
+
38
+ recipe = frame.payload[:recipe]
39
+ next unless recipe
40
+
41
+ identity_signature = identity_signature_for(graph, frame.id)
42
+ next unless identity_signature == "public"
43
+
44
+ target = target_for_frame(frame)
45
+ next unless target
46
+
47
+ stream_name(
48
+ target: target,
49
+ identity_signature: identity_signature,
50
+ sharing_signature: signature_for(recipe)
51
+ )
52
+ end.uniq.sort
53
+ end
54
+
55
+ def identity_signature_for(graph, frame_id)
56
+ identity_dependencies = graph.contained_node_ids(frame_id)
57
+ .flat_map { |owner_id| graph.dependencies_for(owner_id) }
58
+ .select { |dependency| Dependencies.partitioning_identity?(dependency) }
59
+ .uniq(&:cache_key)
60
+ return "public" if identity_dependencies.empty?
61
+
62
+ Digest::SHA256.hexdigest(identity_dependencies.map(&:identity_key).sort_by(&:inspect).inspect)[0, 16]
63
+ end
64
+
65
+ def target_for_frame(frame)
66
+ case frame.payload.fetch(:kind)
67
+ when "render_site"
68
+ Targeting::Target.new("render_site", frame.payload.fetch(:site_id), "shared render-site frame")
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,387 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+ require "active_support/notifications"
5
+ require "securerandom"
6
+ require_relative "active_record_subscription_persistence"
7
+ require_relative "active_registry"
8
+ require_relative "async_durable_writer"
9
+ require_relative "json_snapshot"
10
+ require_relative "layered_reverse_index"
11
+ require_relative "persistent_reverse_index"
12
+ require_relative "reverse_index"
13
+ require_relative "store"
14
+
15
+ module Upkeep
16
+ module Subscriptions
17
+ class ActiveRecordStore
18
+ LOOKUP_NOTIFICATION = LayeredReverseIndex::LOOKUP_NOTIFICATION
19
+ REGISTER_NOTIFICATION = "register_subscription_store.upkeep"
20
+ ACTIVATE_NOTIFICATION = "activate_subscription_store.upkeep"
21
+ PERSIST_NOTIFICATION = ActiveRecordSubscriptionPersistence::PERSIST_NOTIFICATION
22
+ DURABILITY_MODE = "async_subscription_row_index_on_subscribe"
23
+ INDEX_DURABILITY = "on_subscribe"
24
+ REQUIRED_SCHEMA = {
25
+ "upkeep_subscriptions" => {
26
+ "id" => :string,
27
+ "subscriber_id" => :string,
28
+ "recorder_snapshot" => :json,
29
+ "metadata" => :json,
30
+ "subscription_shape_key" => :string,
31
+ "created_at" => :datetime,
32
+ "updated_at" => :datetime
33
+ },
34
+ "upkeep_subscription_index_entries" => {
35
+ "subscription_id" => :string,
36
+ "lookup_key_digest" => :string,
37
+ "dependency_source" => :string,
38
+ "lookup_table" => :string,
39
+ "lookup_record_id_snapshot" => :json,
40
+ "lookup_attribute" => :string,
41
+ "dependency_table" => :string,
42
+ "dependency_predicate_digest" => :string,
43
+ "dependency_metadata_snapshot" => :json,
44
+ "owner_ids_snapshot" => :json,
45
+ "created_at" => :datetime,
46
+ "updated_at" => :datetime
47
+ },
48
+ "upkeep_subscription_shape_index_entries" => {
49
+ "subscription_shape_key" => :string,
50
+ "lookup_key_digest" => :string,
51
+ "dependency_source" => :string,
52
+ "lookup_table" => :string,
53
+ "lookup_record_id_snapshot" => :json,
54
+ "lookup_attribute" => :string,
55
+ "dependency_table" => :string,
56
+ "dependency_predicate_digest" => :string,
57
+ "dependency_metadata_snapshot" => :json,
58
+ "owner_ids_snapshot" => :json,
59
+ "created_at" => :datetime,
60
+ "updated_at" => :datetime
61
+ }
62
+ }.freeze
63
+
64
+ class SubscriptionRecord < ActiveRecord::Base
65
+ self.table_name = "upkeep_subscriptions"
66
+ self.primary_key = "id"
67
+
68
+ has_many :index_entries,
69
+ class_name: "Upkeep::Subscriptions::ActiveRecordStore::IndexEntryRecord",
70
+ foreign_key: "subscription_id",
71
+ dependent: :delete_all
72
+ end
73
+
74
+ class IndexEntryRecord < ActiveRecord::Base
75
+ self.table_name = "upkeep_subscription_index_entries"
76
+
77
+ belongs_to :subscription,
78
+ class_name: "Upkeep::Subscriptions::ActiveRecordStore::SubscriptionRecord",
79
+ foreign_key: "subscription_id"
80
+ end
81
+
82
+ class ShapeIndexEntryRecord < ActiveRecord::Base
83
+ self.table_name = "upkeep_subscription_shape_index_entries"
84
+ end
85
+
86
+ attr_reader :reverse_index
87
+
88
+ DeferredIndexWrite = Data.define(:subscription, :entries)
89
+
90
+ def initialize(subscription_record: SubscriptionRecord, index_record: IndexEntryRecord, shape_index_record: ShapeIndexEntryRecord)
91
+ @subscription_record = subscription_record
92
+ @index_record = index_record
93
+ @shape_index_record = shape_index_record
94
+ @index_builder = ReverseIndex.new
95
+ @pending_registry = ActiveRegistry.new
96
+ @active_registry = ActiveRegistry.new
97
+ @deferred_index_writes = {}
98
+ @deferred_index_mutex = Mutex.new
99
+ @persistence = ActiveRecordSubscriptionPersistence.new(
100
+ subscription_record: subscription_record,
101
+ index_record: index_record,
102
+ shape_index_record: shape_index_record,
103
+ index_builder: index_builder
104
+ )
105
+ persistent_index = PersistentReverseIndex.new(
106
+ reverse_index: index_builder,
107
+ index_record: index_record,
108
+ shape_index_record: shape_index_record,
109
+ subscription_record: subscription_record
110
+ )
111
+ @reverse_index = LayeredReverseIndex.new(
112
+ active_index: active_registry,
113
+ persistent_index: persistent_index,
114
+ persistent_count: -> { persistence.count },
115
+ store: "active_record",
116
+ pending_index: pending_registry
117
+ )
118
+ @durable_writer = AsyncDurableWriter.new { |jobs| persistence.persist_jobs(jobs) }
119
+ end
120
+
121
+ def self.available?(connect: false)
122
+ schema_errors(connect: connect).empty?
123
+ end
124
+
125
+ def self.schema_errors(connect: false)
126
+ return ["Active Record is not connected"] unless ActiveRecord::Base.connected? || connect
127
+
128
+ connection = ActiveRecord::Base.connection
129
+ REQUIRED_SCHEMA.flat_map { |table, columns| schema_errors_for_table(connection, table, columns) }
130
+ rescue ActiveRecord::ConnectionNotEstablished, ActiveRecord::NoDatabaseError => error
131
+ [error.message]
132
+ rescue ActiveRecord::StatementInvalid => error
133
+ ["database schema could not be inspected: #{error.message}"]
134
+ end
135
+
136
+ def self.schema_errors_for_table(connection, table, required_columns)
137
+ unless connection.data_source_exists?(table)
138
+ return ["missing table #{table}"]
139
+ end
140
+
141
+ columns = connection.columns(table).to_h { |column| [column.name, column] }
142
+ required_columns.filter_map do |column_name, expected_type|
143
+ column = columns[column_name]
144
+ if column.nil?
145
+ "missing column #{table}.#{column_name}"
146
+ elsif !compatible_column_type?(column, expected_type)
147
+ "#{table}.#{column_name} must be #{expected_column_description(expected_type)}, found #{column.sql_type.inspect}"
148
+ end
149
+ end
150
+ end
151
+ private_class_method :schema_errors_for_table
152
+
153
+ def self.compatible_column_type?(column, expected_type)
154
+ case expected_type
155
+ when :json
156
+ [:json, :jsonb].include?(column.type) || column.sql_type.to_s.downcase.include?("json")
157
+ when :string
158
+ [:string, :text].include?(column.type)
159
+ when :datetime
160
+ [:datetime, :time, :date].include?(column.type)
161
+ else
162
+ column.type == expected_type
163
+ end
164
+ end
165
+ private_class_method :compatible_column_type?
166
+
167
+ def self.expected_column_description(expected_type)
168
+ expected_type == :json ? "json/jsonb" : expected_type.to_s
169
+ end
170
+ private_class_method :expected_column_description
171
+
172
+ def register(subscriber_id:, recorder:, metadata: {}, entries: nil)
173
+ if ActiveSupport::Notifications.notifier.listening?(REGISTER_NOTIFICATION)
174
+ payload = { store: "active_record" }
175
+ ActiveSupport::Notifications.instrument(REGISTER_NOTIFICATION, payload) do
176
+ register_subscription(subscriber_id, recorder, metadata, entries: entries, payload: payload)
177
+ end
178
+ else
179
+ register_subscription(subscriber_id, recorder, metadata, entries: entries)
180
+ end
181
+ end
182
+
183
+ def drain = durable_writer.drain
184
+
185
+ def shutdown
186
+ clear_deferred_index_writes
187
+ durable_writer.shutdown
188
+ end
189
+
190
+ def activate(id)
191
+ if ActiveSupport::Notifications.notifier.listening?(ACTIVATE_NOTIFICATION)
192
+ payload = { store: "active_record", subscription_id: id }
193
+ ActiveSupport::Notifications.instrument(ACTIVATE_NOTIFICATION, payload) do
194
+ activate_subscription(id, payload: payload)
195
+ end
196
+ else
197
+ activate_subscription(id)
198
+ end
199
+ end
200
+
201
+ def touch(id, now: Time.now)
202
+ fetch(id)
203
+ metadata = { "last_seen_at" => now.utc.iso8601 }
204
+ pending_registry.touch(id, metadata: metadata)
205
+ active_registry.touch(id, metadata: metadata)
206
+ activate(id)
207
+ durable_writer.drain
208
+ persistence.touch(id, metadata: metadata, now: now)
209
+ end
210
+
211
+ def unregister(ids)
212
+ ids = Array(ids)
213
+ pending_registry.unregister(ids)
214
+ active_registry.unregister(ids)
215
+ delete_deferred_index_writes(ids)
216
+ persisted_ids = durable_writer.cancel(ids)
217
+ persistence.delete(persisted_ids)
218
+ ids.size
219
+ end
220
+
221
+ def prune_stale!(older_than:)
222
+ durable_writer.drain
223
+ stale_ids = persistence.prune_stale!(older_than: older_than)
224
+ active_registry.unregister(stale_ids)
225
+ stale_ids.size
226
+ end
227
+
228
+ def fetch(id)
229
+ active_registry.fetch(id) || pending_registry.fetch(id) || persistence.fetch(id)
230
+ rescue ActiveRecord::RecordNotFound
231
+ raise NotFound, id
232
+ end
233
+
234
+ def explain(id)
235
+ fetch(id).explain
236
+ end
237
+
238
+ def subscriptions
239
+ persistent_count = persistence.count
240
+ in_memory_subscriptions = (active_registry.subscriptions + pending_registry.subscriptions).to_h do |subscription|
241
+ [subscription.id, subscription]
242
+ end
243
+ return in_memory_subscriptions.values if in_memory_subscriptions.size >= persistent_count
244
+
245
+ seen_ids = {}
246
+ persisted = persistence.subscriptions.map do |subscription|
247
+ seen_ids[subscription.id] = true
248
+ in_memory_subscriptions.fetch(subscription.id, subscription)
249
+ end
250
+ persisted + in_memory_subscriptions.values.reject { |subscription| seen_ids[subscription.id] }
251
+ end
252
+
253
+ def reset
254
+ clear_deferred_index_writes
255
+ durable_writer.drain
256
+ pending_registry.reset
257
+ active_registry.reset
258
+ persistence.reset
259
+ end
260
+
261
+ def summary
262
+ persistent_count = persistence.count
263
+ pending_count = pending_registry.count
264
+ active_count = active_registry.count
265
+ {
266
+ subscriptions: [persistent_count, active_count + pending_count].max,
267
+ persistent_subscriptions: persistent_count,
268
+ pending_subscriptions: pending_count,
269
+ active_subscriptions: active_count,
270
+ deferred_index_subscriptions: deferred_index_count,
271
+ reverse_index: reverse_index.summary
272
+ }
273
+ end
274
+
275
+ private
276
+
277
+ attr_reader :subscription_record, :index_record, :shape_index_record, :index_builder, :pending_registry, :active_registry, :persistence, :durable_writer
278
+
279
+ def register_subscription(subscriber_id, recorder, metadata, entries: nil, payload: nil)
280
+ recorder.flush_pending_dependencies if recorder.respond_to?(:flush_pending_dependencies)
281
+ subscription = Subscription.new(
282
+ next_subscription_id,
283
+ subscriber_id,
284
+ recorder,
285
+ recorder.graph,
286
+ metadata
287
+ )
288
+
289
+ entries = unique_entries(materialize_entries(subscription, entries))
290
+ if payload
291
+ payload[:subscription_id] = subscription.id
292
+ payload[:dependency_entries] = entries.size
293
+ payload[:mode] = "pending_activation"
294
+ payload[:durability] = DURABILITY_MODE
295
+ payload[:index_durability] = INDEX_DURABILITY
296
+ end
297
+
298
+ pending_registry.register(subscription, entries: entries)
299
+ durable_writer.enqueue(subscription, entries: entries, operation: :persist_subscription)
300
+ defer_index_write(subscription, entries)
301
+ subscription
302
+ end
303
+
304
+ def activate_subscription(id, payload: nil)
305
+ subscription, entries, source = activation_index_write(id)
306
+ unless subscription
307
+ payload[:activated] = false if payload
308
+ payload[:miss_reason] = "no_subscription" if payload
309
+ return false
310
+ end
311
+
312
+ unless source == :active
313
+ active_registry.register(subscription, entries: entries)
314
+ pending_registry.unregister(id)
315
+ durable_writer.enqueue(subscription, entries: entries, operation: :persist_index)
316
+ end
317
+
318
+ if payload
319
+ payload[:activated] = true
320
+ payload[:activation_source] = source
321
+ payload[:dependency_entries] = entries.size
322
+ payload[:active_subscriptions] = active_registry.count
323
+ payload[:pending_subscriptions] = pending_registry.count
324
+ end
325
+
326
+ true
327
+ end
328
+
329
+ def defer_index_write(subscription, entries)
330
+ @deferred_index_mutex.synchronize do
331
+ @deferred_index_writes[subscription.id] = DeferredIndexWrite.new(subscription, entries)
332
+ end
333
+ end
334
+
335
+ def activation_index_write(id)
336
+ deferred_write = take_deferred_index_write(id)
337
+ if deferred_write
338
+ subscription = pending_registry.fetch(id) || active_registry.fetch(id) || deferred_write.subscription
339
+ return [subscription, deferred_write.entries, :pending]
340
+ end
341
+
342
+ if (subscription = active_registry.fetch(id))
343
+ [subscription, [], :active]
344
+ elsif (subscription = pending_registry.fetch(id))
345
+ [subscription, unique_entries(index_builder.entries_for_subscription(subscription)), :pending]
346
+ else
347
+ subscription, entries = persistence.fetch_with_index_entries(id)
348
+ [subscription, entries, :persistent]
349
+ end
350
+ rescue ActiveRecord::RecordNotFound
351
+ [nil, nil, nil]
352
+ end
353
+
354
+ def take_deferred_index_write(id)
355
+ @deferred_index_mutex.synchronize { @deferred_index_writes.delete(id) }
356
+ end
357
+
358
+ def delete_deferred_index_writes(ids)
359
+ @deferred_index_mutex.synchronize { ids.each { |id| @deferred_index_writes.delete(id) } }
360
+ end
361
+
362
+ def clear_deferred_index_writes
363
+ @deferred_index_mutex.synchronize { @deferred_index_writes.clear }
364
+ end
365
+
366
+ def deferred_index_count
367
+ @deferred_index_mutex.synchronize { @deferred_index_writes.size }
368
+ end
369
+
370
+ def unique_entries(entries)
371
+ entries.uniq { |entry| [entry.owner_id, entry.dependency_cache_key] }
372
+ end
373
+
374
+ def materialize_entries(subscription, entries)
375
+ if entries
376
+ index_builder.entries_for_subscription_instance(entries, subscription)
377
+ else
378
+ index_builder.entries_for_subscription(subscription)
379
+ end
380
+ end
381
+
382
+ def next_subscription_id
383
+ "subscription-#{SecureRandom.uuid}"
384
+ end
385
+ end
386
+ end
387
+ end