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,398 @@
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 "base_store"
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 < BaseStore
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 = "sync_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
+ end
119
+
120
+ def self.available?(connect: false)
121
+ schema_errors(connect: connect).empty?
122
+ end
123
+
124
+ def self.schema_errors(connect: false)
125
+ return ["Active Record is not connected"] unless ActiveRecord::Base.connected? || connect
126
+
127
+ connection = ActiveRecord::Base.connection
128
+ REQUIRED_SCHEMA.flat_map { |table, columns| schema_errors_for_table(connection, table, columns) }
129
+ rescue ActiveRecord::ConnectionNotEstablished, ActiveRecord::NoDatabaseError => error
130
+ [error.message]
131
+ rescue ActiveRecord::StatementInvalid => error
132
+ ["database schema could not be inspected: #{error.message}"]
133
+ end
134
+
135
+ def self.schema_errors_for_table(connection, table, required_columns)
136
+ unless connection.data_source_exists?(table)
137
+ return ["missing table #{table}"]
138
+ end
139
+
140
+ columns = connection.columns(table).to_h { |column| [column.name, column] }
141
+ required_columns.filter_map do |column_name, expected_type|
142
+ column = columns[column_name]
143
+ if column.nil?
144
+ "missing column #{table}.#{column_name}"
145
+ elsif !compatible_column_type?(column, expected_type)
146
+ "#{table}.#{column_name} must be #{expected_column_description(expected_type)}, found #{column.sql_type.inspect}"
147
+ end
148
+ end
149
+ end
150
+ private_class_method :schema_errors_for_table
151
+
152
+ def self.compatible_column_type?(column, expected_type)
153
+ case expected_type
154
+ when :json
155
+ [:json, :jsonb].include?(column.type) || column.sql_type.to_s.downcase.include?("json")
156
+ when :string
157
+ [:string, :text].include?(column.type)
158
+ when :datetime
159
+ [:datetime, :time, :date].include?(column.type)
160
+ else
161
+ column.type == expected_type
162
+ end
163
+ end
164
+ private_class_method :compatible_column_type?
165
+
166
+ def self.expected_column_description(expected_type)
167
+ expected_type == :json ? "json/jsonb" : expected_type.to_s
168
+ end
169
+ private_class_method :expected_column_description
170
+
171
+ def register(subscriber_id:, recorder:, metadata: {}, entries: nil)
172
+ subscription = with_optional_notification(REGISTER_NOTIFICATION, { store: "active_record" }) do |payload|
173
+ register_subscription(subscriber_id, recorder, metadata, entries: entries, payload: payload)
174
+ end
175
+ trim_opportunistically
176
+ subscription
177
+ end
178
+
179
+ def shutdown
180
+ clear_deferred_index_writes
181
+ end
182
+
183
+ def activate(id)
184
+ with_optional_notification(ACTIVATE_NOTIFICATION, { store: "active_record", subscription_id: id }) do |payload|
185
+ activate_subscription(id, payload: payload)
186
+ end
187
+ end
188
+
189
+ def fetch_for_composition(id)
190
+ local = active_registry.fetch(id) || pending_registry.fetch(id)
191
+ return local if local
192
+
193
+ subscription, entries = persistence.fetch_with_index_entries(id)
194
+ entries.each do |entry|
195
+ subscription.graph.add_dependency(entry.owner_id, entry.dependency)
196
+ end
197
+ recorder = Runtime::Recorder.new(graph: subscription.graph)
198
+ Subscription.new(
199
+ subscription.id,
200
+ subscription.subscriber_id,
201
+ recorder,
202
+ recorder.graph,
203
+ subscription.metadata
204
+ )
205
+ rescue ActiveRecord::RecordNotFound
206
+ raise NotFound, id
207
+ end
208
+
209
+ def prune_stale!(older_than: stale_threshold, limit: nil)
210
+ stale_ids = persistence.prune_stale!(older_than: older_than, limit: limit)
211
+ active_registry.unregister(stale_ids)
212
+ stale_ids.size
213
+ end
214
+
215
+ def subscriptions
216
+ persistent_count = persistence.count
217
+ in_memory_subscriptions = (active_registry.subscriptions + pending_registry.subscriptions).to_h do |subscription|
218
+ [subscription.id, subscription]
219
+ end
220
+ return in_memory_subscriptions.values if in_memory_subscriptions.size >= persistent_count
221
+
222
+ seen_ids = {}
223
+ persisted = persistence.subscriptions.map do |subscription|
224
+ seen_ids[subscription.id] = true
225
+ in_memory_subscriptions.fetch(subscription.id, subscription)
226
+ end
227
+ persisted + in_memory_subscriptions.values.reject { |subscription| seen_ids[subscription.id] }
228
+ end
229
+
230
+ def reset
231
+ clear_deferred_index_writes
232
+ pending_registry.reset
233
+ active_registry.reset
234
+ persistence.reset
235
+ end
236
+
237
+ def summary
238
+ persistent_count = persistence.count
239
+ pending_count = pending_registry.count
240
+ active_count = active_registry.count
241
+ {
242
+ subscriptions: [persistent_count, active_count + pending_count].max,
243
+ persistent_subscriptions: persistent_count,
244
+ pending_subscriptions: pending_count,
245
+ active_subscriptions: active_count,
246
+ deferred_index_subscriptions: deferred_index_count,
247
+ reverse_index: reverse_index.summary
248
+ }
249
+ end
250
+
251
+ private
252
+
253
+ attr_reader :subscription_record, :index_record, :shape_index_record, :index_builder, :pending_registry, :active_registry, :persistence
254
+
255
+ def store_label
256
+ "active_record"
257
+ end
258
+
259
+ def after_touch(id, metadata:, now:)
260
+ activate(id)
261
+ persistence.touch(id, metadata: metadata, now: now)
262
+ end
263
+
264
+ def after_unregister(ids)
265
+ delete_deferred_index_writes(ids)
266
+ persistence.delete(ids)
267
+ end
268
+
269
+ def fetch_missing(id)
270
+ persistence.fetch(id)
271
+ rescue ActiveRecord::RecordNotFound
272
+ raise NotFound, id
273
+ end
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
+ persist_now(subscription, 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
+ persist_now(subscription, 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
+ # Synchronous so other processes see the row (registration) and the index
326
+ # entries (activation) immediately. Persistence failures degrade to
327
+ # in-process liveness instead of failing the request or the activation.
328
+ def persist_now(subscription, entries, operation:)
329
+ persistence.persist_jobs([ActiveRecordSubscriptionPersistence::Job.new(subscription, entries, operation)])
330
+ rescue StandardError => error
331
+ warn_persist_failure(subscription, operation, error)
332
+ end
333
+
334
+ def warn_persist_failure(subscription, operation, error)
335
+ return unless defined?(::Rails) && ::Rails.respond_to?(:logger) && ::Rails.logger
336
+
337
+ target = operation == :persist_subscription ? "subscription row" : "index entries"
338
+ ::Rails.logger.warn(
339
+ "Upkeep could not persist #{target} for #{subscription.id} " \
340
+ "(#{error.class}: #{error.message}); the subscription stays live in this process only"
341
+ )
342
+ end
343
+
344
+ def defer_index_write(subscription, entries)
345
+ @deferred_index_mutex.synchronize do
346
+ @deferred_index_writes[subscription.id] = DeferredIndexWrite.new(subscription, entries)
347
+ end
348
+ end
349
+
350
+ def activation_index_write(id)
351
+ deferred_write = take_deferred_index_write(id)
352
+ if deferred_write
353
+ subscription = pending_registry.fetch(id) || active_registry.fetch(id) || deferred_write.subscription
354
+ return [subscription, deferred_write.entries, :pending]
355
+ end
356
+
357
+ if (subscription = active_registry.fetch(id))
358
+ [subscription, [], :active]
359
+ elsif (subscription = pending_registry.fetch(id))
360
+ [subscription, unique_entries(index_builder.entries_for_subscription(subscription)), :pending]
361
+ else
362
+ subscription, entries = persistence.fetch_with_index_entries(id)
363
+ [subscription, entries, :persistent]
364
+ end
365
+ rescue ActiveRecord::RecordNotFound
366
+ [nil, nil, nil]
367
+ end
368
+
369
+ def take_deferred_index_write(id)
370
+ @deferred_index_mutex.synchronize { @deferred_index_writes.delete(id) }
371
+ end
372
+
373
+ def delete_deferred_index_writes(ids)
374
+ @deferred_index_mutex.synchronize { ids.each { |id| @deferred_index_writes.delete(id) } }
375
+ end
376
+
377
+ def clear_deferred_index_writes
378
+ @deferred_index_mutex.synchronize { @deferred_index_writes.clear }
379
+ end
380
+
381
+ def deferred_index_count
382
+ @deferred_index_mutex.synchronize { @deferred_index_writes.size }
383
+ end
384
+
385
+ def unique_entries(entries)
386
+ entries.uniq { |entry| [entry.owner_id, entry.dependency_cache_key] }
387
+ end
388
+
389
+ def materialize_entries(subscription, entries)
390
+ if entries
391
+ index_builder.entries_for_subscription_instance(entries, subscription)
392
+ else
393
+ index_builder.entries_for_subscription(subscription)
394
+ end
395
+ end
396
+ end
397
+ end
398
+ end