upkeep-rails 0.1.6

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.

Potentially problematic release.


This version of upkeep-rails might be problematic. Click here for more details.

Files changed (77) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +614 -0
  4. data/docs/architecture/ambient-inputs-roadmap.md +308 -0
  5. data/docs/architecture/herb-roadmap.md +324 -0
  6. data/docs/architecture/identity-and-sharing.md +306 -0
  7. data/docs/architecture/query-dependencies.md +230 -0
  8. data/docs/architecture/subscription-store-contract.md +66 -0
  9. data/docs/cost-model-roadmap.md +704 -0
  10. data/docs/guides/getting-started.md +462 -0
  11. data/docs/handoff-2026-05-15.md +230 -0
  12. data/docs/production_roadmap.md +372 -0
  13. data/docs/shared-warm-scale-roadmap.md +214 -0
  14. data/docs/single-subscriber-cold-roadmap.md +192 -0
  15. data/docs/stress-test-findings.md +310 -0
  16. data/docs/testing.md +143 -0
  17. data/lib/generators/upkeep/install/install_generator.rb +127 -0
  18. data/lib/generators/upkeep/install/templates/create_upkeep_subscriptions.rb.erb +49 -0
  19. data/lib/generators/upkeep/install/templates/subscription.js +99 -0
  20. data/lib/generators/upkeep/install/templates/upkeep.rb +63 -0
  21. data/lib/upkeep/active_record_query.rb +294 -0
  22. data/lib/upkeep/capture/request.rb +150 -0
  23. data/lib/upkeep/dag/subscription_shape.rb +244 -0
  24. data/lib/upkeep/dag.rb +370 -0
  25. data/lib/upkeep/delivery/action_cable_adapter.rb +43 -0
  26. data/lib/upkeep/delivery/async_dispatcher.rb +102 -0
  27. data/lib/upkeep/delivery/broadcast_transport.rb +89 -0
  28. data/lib/upkeep/delivery/transport.rb +194 -0
  29. data/lib/upkeep/delivery/turbo_streams.rb +302 -0
  30. data/lib/upkeep/delivery.rb +7 -0
  31. data/lib/upkeep/dependencies.rb +518 -0
  32. data/lib/upkeep/herb/developer_report.rb +135 -0
  33. data/lib/upkeep/herb/manifest_cache.rb +83 -0
  34. data/lib/upkeep/herb/manifest_diff.rb +183 -0
  35. data/lib/upkeep/herb/source_instrumenter.rb +149 -0
  36. data/lib/upkeep/herb/template_manifest.rb +514 -0
  37. data/lib/upkeep/invalidation/collection_append.rb +84 -0
  38. data/lib/upkeep/invalidation/collection_member_replace.rb +78 -0
  39. data/lib/upkeep/invalidation/collection_prepend.rb +84 -0
  40. data/lib/upkeep/invalidation/collection_remove.rb +57 -0
  41. data/lib/upkeep/invalidation/planner.rb +360 -0
  42. data/lib/upkeep/invalidation.rb +7 -0
  43. data/lib/upkeep/rails/action_view_capture.rb +821 -0
  44. data/lib/upkeep/rails/activation_token.rb +55 -0
  45. data/lib/upkeep/rails/cable/channel.rb +143 -0
  46. data/lib/upkeep/rails/cable/subscriber_identity.rb +341 -0
  47. data/lib/upkeep/rails/cable.rb +4 -0
  48. data/lib/upkeep/rails/client_subscription.rb +45 -0
  49. data/lib/upkeep/rails/configuration.rb +245 -0
  50. data/lib/upkeep/rails/controller_runtime.rb +137 -0
  51. data/lib/upkeep/rails/delivery_job.rb +29 -0
  52. data/lib/upkeep/rails/install.rb +28 -0
  53. data/lib/upkeep/rails/railtie.rb +50 -0
  54. data/lib/upkeep/rails/replay.rb +176 -0
  55. data/lib/upkeep/rails/testing.rb +97 -0
  56. data/lib/upkeep/rails.rb +349 -0
  57. data/lib/upkeep/replay.rb +408 -0
  58. data/lib/upkeep/runtime.rb +1100 -0
  59. data/lib/upkeep/shared_streams.rb +72 -0
  60. data/lib/upkeep/subscriptions/active_record_store.rb +383 -0
  61. data/lib/upkeep/subscriptions/active_record_subscription_persistence.rb +407 -0
  62. data/lib/upkeep/subscriptions/active_registry.rb +87 -0
  63. data/lib/upkeep/subscriptions/async_durable_writer.rb +131 -0
  64. data/lib/upkeep/subscriptions/json_snapshot.rb +98 -0
  65. data/lib/upkeep/subscriptions/layered_reverse_index.rb +129 -0
  66. data/lib/upkeep/subscriptions/persistent_reverse_index.rb +223 -0
  67. data/lib/upkeep/subscriptions/registrar.rb +36 -0
  68. data/lib/upkeep/subscriptions/reverse_index.rb +298 -0
  69. data/lib/upkeep/subscriptions/shape.rb +116 -0
  70. data/lib/upkeep/subscriptions/store.rb +171 -0
  71. data/lib/upkeep/subscriptions.rb +7 -0
  72. data/lib/upkeep/targeting.rb +135 -0
  73. data/lib/upkeep/version.rb +5 -0
  74. data/lib/upkeep-rails.rb +3 -0
  75. data/lib/upkeep.rb +14 -0
  76. data/upkeep-rails.gemspec +54 -0
  77. metadata +320 -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,383 @@
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 subscriptions
235
+ persistent_count = persistence.count
236
+ in_memory_subscriptions = (active_registry.subscriptions + pending_registry.subscriptions).to_h do |subscription|
237
+ [subscription.id, subscription]
238
+ end
239
+ return in_memory_subscriptions.values if in_memory_subscriptions.size >= persistent_count
240
+
241
+ seen_ids = {}
242
+ persisted = persistence.subscriptions.map do |subscription|
243
+ seen_ids[subscription.id] = true
244
+ in_memory_subscriptions.fetch(subscription.id, subscription)
245
+ end
246
+ persisted + in_memory_subscriptions.values.reject { |subscription| seen_ids[subscription.id] }
247
+ end
248
+
249
+ def reset
250
+ clear_deferred_index_writes
251
+ durable_writer.drain
252
+ pending_registry.reset
253
+ active_registry.reset
254
+ persistence.reset
255
+ end
256
+
257
+ def summary
258
+ persistent_count = persistence.count
259
+ pending_count = pending_registry.count
260
+ active_count = active_registry.count
261
+ {
262
+ subscriptions: [persistent_count, active_count + pending_count].max,
263
+ persistent_subscriptions: persistent_count,
264
+ pending_subscriptions: pending_count,
265
+ active_subscriptions: active_count,
266
+ deferred_index_subscriptions: deferred_index_count,
267
+ reverse_index: reverse_index.summary
268
+ }
269
+ end
270
+
271
+ private
272
+
273
+ attr_reader :subscription_record, :index_record, :shape_index_record, :index_builder, :pending_registry, :active_registry, :persistence, :durable_writer
274
+
275
+ def register_subscription(subscriber_id, recorder, metadata, entries: nil, payload: nil)
276
+ recorder.flush_pending_dependencies if recorder.respond_to?(:flush_pending_dependencies)
277
+ subscription = Subscription.new(
278
+ next_subscription_id,
279
+ subscriber_id,
280
+ recorder,
281
+ recorder.graph,
282
+ metadata
283
+ )
284
+
285
+ entries = unique_entries(materialize_entries(subscription, entries))
286
+ if payload
287
+ payload[:subscription_id] = subscription.id
288
+ payload[:dependency_entries] = entries.size
289
+ payload[:mode] = "pending_activation"
290
+ payload[:durability] = DURABILITY_MODE
291
+ payload[:index_durability] = INDEX_DURABILITY
292
+ end
293
+
294
+ pending_registry.register(subscription, entries: entries)
295
+ durable_writer.enqueue(subscription, entries: entries, operation: :persist_subscription)
296
+ defer_index_write(subscription, entries)
297
+ subscription
298
+ end
299
+
300
+ def activate_subscription(id, payload: nil)
301
+ subscription, entries, source = activation_index_write(id)
302
+ unless subscription
303
+ payload[:activated] = false if payload
304
+ payload[:miss_reason] = "no_subscription" if payload
305
+ return false
306
+ end
307
+
308
+ unless source == :active
309
+ active_registry.register(subscription, entries: entries)
310
+ pending_registry.unregister(id)
311
+ durable_writer.enqueue(subscription, entries: entries, operation: :persist_index)
312
+ end
313
+
314
+ if payload
315
+ payload[:activated] = true
316
+ payload[:activation_source] = source
317
+ payload[:dependency_entries] = entries.size
318
+ payload[:active_subscriptions] = active_registry.count
319
+ payload[:pending_subscriptions] = pending_registry.count
320
+ end
321
+
322
+ true
323
+ end
324
+
325
+ def defer_index_write(subscription, entries)
326
+ @deferred_index_mutex.synchronize do
327
+ @deferred_index_writes[subscription.id] = DeferredIndexWrite.new(subscription, entries)
328
+ end
329
+ end
330
+
331
+ def activation_index_write(id)
332
+ deferred_write = take_deferred_index_write(id)
333
+ if deferred_write
334
+ subscription = pending_registry.fetch(id) || active_registry.fetch(id) || deferred_write.subscription
335
+ return [subscription, deferred_write.entries, :pending]
336
+ end
337
+
338
+ if (subscription = active_registry.fetch(id))
339
+ [subscription, [], :active]
340
+ elsif (subscription = pending_registry.fetch(id))
341
+ [subscription, unique_entries(index_builder.entries_for_subscription(subscription)), :pending]
342
+ else
343
+ subscription, entries = persistence.fetch_with_index_entries(id)
344
+ [subscription, entries, :persistent]
345
+ end
346
+ rescue ActiveRecord::RecordNotFound
347
+ [nil, nil, nil]
348
+ end
349
+
350
+ def take_deferred_index_write(id)
351
+ @deferred_index_mutex.synchronize { @deferred_index_writes.delete(id) }
352
+ end
353
+
354
+ def delete_deferred_index_writes(ids)
355
+ @deferred_index_mutex.synchronize { ids.each { |id| @deferred_index_writes.delete(id) } }
356
+ end
357
+
358
+ def clear_deferred_index_writes
359
+ @deferred_index_mutex.synchronize { @deferred_index_writes.clear }
360
+ end
361
+
362
+ def deferred_index_count
363
+ @deferred_index_mutex.synchronize { @deferred_index_writes.size }
364
+ end
365
+
366
+ def unique_entries(entries)
367
+ entries.uniq { |entry| [entry.owner_id, entry.dependency_cache_key] }
368
+ end
369
+
370
+ def materialize_entries(subscription, entries)
371
+ if entries
372
+ index_builder.entries_for_subscription_instance(entries, subscription)
373
+ else
374
+ index_builder.entries_for_subscription(subscription)
375
+ end
376
+ end
377
+
378
+ def next_subscription_id
379
+ "subscription-#{SecureRandom.uuid}"
380
+ end
381
+ end
382
+ end
383
+ end