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,466 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/notifications"
4
+ require_relative "capture/request"
5
+ require_relative "subscriptions/registrar"
6
+ require_relative "rails/configuration"
7
+ require_relative "rails/cluster_guard"
8
+ require_relative "rails/activation_token"
9
+ require_relative "rails/replay"
10
+ require_relative "rails/action_view_capture"
11
+ require_relative "rails/cable"
12
+ require_relative "rails/client_subscription"
13
+ require_relative "rails/controller_runtime"
14
+ require_relative "rails/job_runtime"
15
+ require_relative "rails/install"
16
+ require_relative "rails/testing"
17
+ require_relative "rails/railtie" if defined?(::Rails::Railtie)
18
+
19
+ module Upkeep
20
+ module Rails
21
+ SUBSCRIPTION_IDENTITY = "upkeep.subscription_identity"
22
+ REQUEST_CAPTURE = "request_capture.upkeep"
23
+ DELIVERY_ENQUEUE = "delivery_enqueue.upkeep"
24
+ DELIVERY_ENQUEUE_ERROR = "delivery_enqueue_error.upkeep"
25
+ INTERNAL_DELIVERY_TABLES = %w[
26
+ upkeep_subscriptions
27
+ upkeep_subscription_index_entries
28
+ upkeep_subscription_shape_index_entries
29
+ ].freeze
30
+
31
+ class << self
32
+ def configuration
33
+ @configuration ||= Configuration.new
34
+ end
35
+
36
+ def configure
37
+ yield configuration
38
+ end
39
+
40
+ def subscriptions
41
+ discard_subscription_store! if @subscriptions && subscription_store_config_changed?
42
+ @subscriptions ||= build_subscription_store
43
+ end
44
+
45
+ def transport
46
+ @transport ||= Delivery::BroadcastTransport.new
47
+ end
48
+
49
+ def reset_runtime!
50
+ @delivery_dispatcher&.shutdown
51
+ @delivery_dispatcher = nil
52
+ @subscription_shape_cache&.reset
53
+ @subscription_registrar = nil
54
+ discard_subscription_store! if @subscriptions
55
+ @subscriptions = build_subscription_store
56
+ @subscriptions.reset
57
+ @transport = Delivery::BroadcastTransport.new
58
+ end
59
+
60
+ def register_controller_subscription(controller, capture)
61
+ return unless subscription_response?(controller, capture)
62
+
63
+ unless capture.recorder.reactive?
64
+ decision = Cable::SubscriberIdentity.decision_for(controller.request, recorder: capture.recorder)
65
+ request_subscription_reload(controller, "refused_boundary") if controller.request.headers["Turbo-Frame"].present?
66
+ instrument_subscription_identity(
67
+ decision,
68
+ registered: false,
69
+ deopt_reason: "refused_boundary",
70
+ refused_boundaries: capture.recorder.refused_boundaries.map(&:reason)
71
+ )
72
+ return
73
+ end
74
+
75
+ recorder, base_subscription = controller_subscription_recorder(controller, capture)
76
+ return unless recorder
77
+
78
+ decision = Cable::SubscriberIdentity.decision_for(controller.request, recorder: recorder)
79
+
80
+ identity = Cable::SubscriberIdentity.derive_from_request(
81
+ controller.request,
82
+ recorder: recorder,
83
+ decision: decision
84
+ )
85
+ unless replacement_identity_valid?(base_subscription, identity)
86
+ request_subscription_reload(controller, "subscriber_identity_changed")
87
+ instrument_subscription_identity(decision, registered: false, deopt_reason: "subscriber_identity_changed")
88
+ return
89
+ end
90
+
91
+ request_signature = base_subscription ?
92
+ metadata_value(base_subscription, :request_signature) :
93
+ capture.signature
94
+ registration = subscription_registrar.register(
95
+ identity: identity,
96
+ decision: decision,
97
+ recorder: recorder,
98
+ signature: request_signature,
99
+ metadata: identity_metadata(decision).merge(
100
+ path: base_subscription ? metadata_value(base_subscription, :path) : controller.request.fullpath,
101
+ stream_name: identity.stream_name,
102
+ request_signature: request_signature.respond_to?(:to_h) ? request_signature.to_h : request_signature,
103
+ replaces_subscription_id: base_subscription&.id
104
+ )
105
+ )
106
+ instrument_subscription_identity(decision, registered: true, subscription: registration.subscription)
107
+
108
+ registration
109
+ rescue Cable::UnidentifiedSubscriber => error
110
+ instrument_subscription_identity(
111
+ decision || Cable::SubscriberIdentity.decision_for(controller.request, recorder: recorder),
112
+ registered: false,
113
+ deopt_reason: "unidentified_identity",
114
+ error: error.message
115
+ )
116
+ nil
117
+ end
118
+
119
+ # Dispatches committed application changes onto the in-process delivery
120
+ # dispatcher (or synchronously when config.deliver_inline is set).
121
+ #
122
+ # ControllerRuntime calls this automatically after non-GET actions. Apps
123
+ # usually should not call it from controllers or models.
124
+ #
125
+ # @param changes [Array<#to_h>] change events to deliver. Defaults to the
126
+ # current runtime change log.
127
+ # @return [Upkeep::Delivery::Transport::DispatchReport]
128
+ def deliver_changes!(changes = Runtime::ChangeLog.drain)
129
+ changes = deliverable_changes(changes)
130
+ record_testing_change_facts(changes)
131
+ return Delivery::Transport::DispatchReport.new([]) if changes.empty?
132
+
133
+ dispatch_changes(changes)
134
+ rescue StandardError => error
135
+ instrument_delivery_enqueue_error(changes, error)
136
+ Delivery::Transport::DispatchReport.new([])
137
+ end
138
+
139
+ # Delivers committed application changes immediately in the current
140
+ # process.
141
+ #
142
+ # This is used by inline delivery (config.deliver_inline). Tests that
143
+ # need deterministic async delivery should use Upkeep::Rails::Testing
144
+ # instead of calling this directly.
145
+ #
146
+ # @param changes [Array<#to_h>] change events to deliver. Defaults to the
147
+ # current runtime change log.
148
+ # @return [Upkeep::Delivery::TurboStreams::Batch, Upkeep::Delivery::Transport::DispatchReport]
149
+ def deliver_changes_now!(changes = Runtime::ChangeLog.drain)
150
+ changes = deliverable_changes(changes)
151
+ record_testing_change_facts(changes)
152
+ return Delivery::Transport::DispatchReport.new([]) if changes.empty?
153
+
154
+ batch = delivery_batch_for([changes])
155
+ deliver_batch(batch)
156
+ end
157
+
158
+ def validate_configuration!(environment: rails_environment)
159
+ return true unless configuration.enabled
160
+
161
+ validate_subscription_store!(environment: environment)
162
+ validate_cluster_safety!(environment: environment)
163
+ true
164
+ end
165
+
166
+ private
167
+
168
+ def delivery_dispatcher
169
+ @delivery_dispatcher ||= Delivery::AsyncDispatcher.new(batch_window: configuration.delivery_batch_window) do |change_sets|
170
+ batch = delivery_batch_for(change_sets)
171
+ deliver_batch(batch)
172
+ end
173
+ end
174
+
175
+ def drain_delivery_dispatcher!
176
+ @delivery_dispatcher&.drain
177
+ end
178
+
179
+ def dispatch_changes(changes)
180
+ payload = {
181
+ inline: configuration.deliver_inline,
182
+ change_count: changes.size
183
+ }
184
+
185
+ ActiveSupport::Notifications.instrument(DELIVERY_ENQUEUE, payload) do
186
+ if configuration.deliver_inline
187
+ deliver_changes_now!(changes)
188
+ else
189
+ delivery_dispatcher.enqueue(changes)
190
+ end
191
+ end
192
+ end
193
+
194
+ def instrument_delivery_enqueue_error(changes, error)
195
+ ActiveSupport::Notifications.instrument(
196
+ DELIVERY_ENQUEUE_ERROR,
197
+ inline: configuration.deliver_inline,
198
+ change_count: changes.size,
199
+ error_class: error.class.name,
200
+ error_message: error.message
201
+ )
202
+ end
203
+
204
+ def subscription_registrar
205
+ @subscription_registrar ||= Subscriptions::Registrar.new(
206
+ store: subscriptions,
207
+ shape_cache: subscription_shape_cache
208
+ )
209
+ end
210
+
211
+ def subscription_shape_cache
212
+ @subscription_shape_cache ||= Subscriptions::ShapeCache.new
213
+ end
214
+
215
+ def delivery_batch_for(change_sets)
216
+ change_sets = compact_change_sets(change_sets)
217
+ return Delivery::TurboStreams::Batch.new([]) if change_sets.empty?
218
+
219
+ planner = Invalidation::Planner.new(store: subscriptions)
220
+ plans = change_sets.map { |changes| planner.plan(changes) }
221
+ plans = plans.reject { |plan| plan.targets.empty? }
222
+ return Delivery::TurboStreams::Batch.new([]) if plans.empty?
223
+
224
+ Delivery::TurboStreams.new.build_many(plans)
225
+ end
226
+
227
+ def deliver_batch(batch)
228
+ record_testing_delivery_batch(batch)
229
+ transport.deliver(batch)
230
+ end
231
+
232
+ def compact_change_sets(change_sets)
233
+ change_sets
234
+ .map { |changes| deliverable_changes(changes) }
235
+ .reject(&:empty?)
236
+ .uniq { |changes| change_set_key(changes) }
237
+ end
238
+
239
+ def deliverable_changes(changes)
240
+ Array(changes).reject { |change| internal_delivery_change?(change) }
241
+ end
242
+
243
+ def record_testing_change_facts(changes)
244
+ return unless defined?(Testing) && Testing.respond_to?(:record_change_facts)
245
+ return if Testing.respond_to?(:capturing_change_facts?) && !Testing.capturing_change_facts?
246
+
247
+ Testing.record_change_facts(changes)
248
+ end
249
+
250
+ def record_testing_delivery_batch(batch)
251
+ return unless defined?(Testing) && Testing.respond_to?(:record_delivery_batch)
252
+ return if Testing.respond_to?(:capturing_broadcasts?) && !Testing.capturing_broadcasts?
253
+
254
+ Testing.record_delivery_batch(batch)
255
+ end
256
+
257
+ def internal_delivery_change?(change)
258
+ INTERNAL_DELIVERY_TABLES.include?(change_value(change, :table).to_s)
259
+ end
260
+
261
+ def change_set_key(changes)
262
+ changes.map { |change| change_key(change) }.sort
263
+ end
264
+
265
+ def change_key(change)
266
+ [
267
+ change_value(change, :type).to_s,
268
+ change_value(change, :table).to_s,
269
+ change_value(change, :model).to_s,
270
+ change_value(change, :id).to_s,
271
+ Array(change_value(change, :changed_attributes)).map(&:to_s).sort,
272
+ change_value(change, :old_values).inspect,
273
+ change_value(change, :new_values).inspect
274
+ ]
275
+ end
276
+
277
+ def change_value(change, key)
278
+ return unless change.respond_to?(:[])
279
+
280
+ change[key] || change[key.to_s]
281
+ end
282
+
283
+ def discard_subscription_store!
284
+ @subscriptions&.shutdown
285
+ @subscriptions = nil
286
+ @subscription_registrar = nil
287
+ end
288
+
289
+ def identity_metadata(decision)
290
+ {
291
+ identity_mode: decision.mode,
292
+ anonymous: decision.anonymous,
293
+ anonymous_deopt_reason: decision.deopt_reason,
294
+ identity_sources: decision.identity_sources,
295
+ identity_names: decision.identity_names
296
+ }.compact
297
+ end
298
+
299
+ def instrument_subscription_identity(decision, registered:, subscription: nil, deopt_reason: nil, **extra)
300
+ ActiveSupport::Notifications.instrument(
301
+ SUBSCRIPTION_IDENTITY,
302
+ {
303
+ registered: registered,
304
+ subscription_id: subscription&.id,
305
+ subscriber_id: subscription&.subscriber_id,
306
+ identity_mode: decision&.mode,
307
+ anonymous: decision&.anonymous,
308
+ anonymous_deopt_reason: deopt_reason || decision&.deopt_reason,
309
+ identity_sources: decision&.identity_sources,
310
+ identity_names: decision&.identity_names
311
+ }.merge(extra)
312
+ )
313
+ end
314
+
315
+ def subscription_response?(controller, capture)
316
+ controller.request.get? &&
317
+ capture.successful? &&
318
+ capture.html_response? &&
319
+ capture.html.include?("</") &&
320
+ capture.recorder.graph.frame_nodes.any?
321
+ end
322
+
323
+ def controller_subscription_recorder(controller, capture)
324
+ target_id = controller.request.headers["Turbo-Frame"].to_s
325
+ return [capture.recorder, nil] if target_id.empty?
326
+
327
+ subscription_id = controller.request.headers[ClientSubscription::ID_HEADER].to_s
328
+ token = controller.request.headers[ClientSubscription::TOKEN_HEADER].to_s
329
+ return [capture.recorder, nil] if subscription_id.empty? && token.empty?
330
+
331
+ unless !subscription_id.empty? && ActivationToken.valid_for_subscription?(token, subscription_id)
332
+ request_subscription_reload(controller, "invalid_base_subscription")
333
+ return
334
+ end
335
+
336
+ base_subscription = subscriptions.fetch_for_composition(subscription_id)
337
+ scope_id = "turbo_frame:#{target_id}"
338
+ recorder = base_subscription.recorder.replace_scope(scope_id, capture.recorder)
339
+ [recorder, base_subscription]
340
+ rescue KeyError
341
+ request_subscription_reload(controller, "missing_frame_scope")
342
+ nil
343
+ end
344
+
345
+ def replacement_identity_valid?(base_subscription, identity)
346
+ return true unless base_subscription
347
+ return true if metadata_value(base_subscription, :identity_mode) == Cable::SubscriberIdentity::ANONYMOUS_PUBLIC_MODE
348
+
349
+ base_subscription.subscriber_id == identity.subscriber_id
350
+ end
351
+
352
+ def request_subscription_reload(controller, reason)
353
+ controller.response.headers[ClientSubscription::ACTION_HEADER] = "reload"
354
+ controller.response.headers["X-Upkeep-Subscription-Reason"] = reason
355
+ end
356
+
357
+ def metadata_value(subscription, key)
358
+ subscription.metadata[key] || subscription.metadata[key.to_s]
359
+ end
360
+
361
+ def build_subscription_store
362
+ case configuration.subscription_store
363
+ when :active_record
364
+ schema_errors = Subscriptions::ActiveRecordStore.schema_errors(connect: true)
365
+ unless schema_errors.empty?
366
+ raise ConfigurationError,
367
+ active_record_subscription_store_error(schema_errors)
368
+ end
369
+
370
+ Subscriptions::ActiveRecordStore.new
371
+ when :memory
372
+ Subscriptions::Store.new
373
+ end.tap do
374
+ @subscription_store_name = configuration.subscription_store
375
+ end
376
+ end
377
+
378
+ def validate_subscription_store!(environment:)
379
+ if production_environment?(environment) && configuration.subscription_store == :memory
380
+ raise ConfigurationError,
381
+ "Upkeep subscription_store=:memory is only for development/test; production requires :active_record."
382
+ end
383
+
384
+ return true unless production_environment?(environment)
385
+ return true unless configuration.subscription_store == :active_record
386
+ schema_errors = Subscriptions::ActiveRecordStore.schema_errors(connect: true)
387
+ return true if schema_errors.empty?
388
+
389
+ raise ConfigurationError, active_record_subscription_store_error(schema_errors, production: true)
390
+ end
391
+
392
+ def active_record_subscription_store_error(schema_errors, production: false)
393
+ prefix = if production
394
+ "Upkeep production boot requires compatible upkeep_subscriptions, " \
395
+ "upkeep_subscription_index_entries, and upkeep_subscription_shape_index_entries tables " \
396
+ "for subscription_store=:active_record."
397
+ else
398
+ "Upkeep subscription_store=:active_record requires compatible upkeep_subscriptions, " \
399
+ "upkeep_subscription_index_entries, and upkeep_subscription_shape_index_entries tables."
400
+ end
401
+
402
+ "#{prefix} Schema errors: #{schema_errors.join("; ")}. Run bin/rails generate upkeep:install " \
403
+ "and bin/rails db:migrate, rebuild stale development/test databases, or set " \
404
+ "config.upkeep.subscription_store = :memory in development/test."
405
+ end
406
+
407
+ def validate_cluster_safety!(environment:, guard: nil)
408
+ guard ||= ClusterGuard.new(
409
+ cable_adapter: resolved_action_cable_adapter,
410
+ worker_count: detected_worker_count,
411
+ subscription_store: configuration.subscription_store,
412
+ environment: environment
413
+ )
414
+ return true if guard.problems.empty?
415
+ raise ConfigurationError, guard.message if guard.error?
416
+
417
+ log_cluster_warning(guard.message)
418
+ true
419
+ end
420
+
421
+ def log_cluster_warning(message)
422
+ return if @cluster_warning_logged
423
+
424
+ @cluster_warning_logged = true
425
+ if defined?(::Rails) && ::Rails.respond_to?(:logger) && ::Rails.logger
426
+ ::Rails.logger.warn("[upkeep] #{message}")
427
+ else
428
+ warn("[upkeep] #{message}")
429
+ end
430
+ end
431
+
432
+ def resolved_action_cable_adapter
433
+ return unless defined?(::ActionCable) && ::ActionCable.respond_to?(:server)
434
+
435
+ cable = ::ActionCable.server.config.cable
436
+ cable && (cable["adapter"] || cable[:adapter])
437
+ rescue StandardError
438
+ nil
439
+ end
440
+
441
+ def detected_worker_count
442
+ puma_worker_count || ENV["WEB_CONCURRENCY"].to_i
443
+ end
444
+
445
+ def puma_worker_count
446
+ return unless defined?(::Puma) && ::Puma.respond_to?(:cli_config)
447
+
448
+ ::Puma.cli_config&.options&.[](:workers)
449
+ rescue StandardError
450
+ nil
451
+ end
452
+
453
+ def production_environment?(environment)
454
+ environment.to_s == "production"
455
+ end
456
+
457
+ def rails_environment
458
+ ::Rails.env if defined?(::Rails) && ::Rails.respond_to?(:env)
459
+ end
460
+
461
+ def subscription_store_config_changed?
462
+ @subscription_store_name != configuration.subscription_store
463
+ end
464
+ end
465
+ end
466
+ end