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,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/notifications"
4
+ require_relative "lookup_instrumentation"
5
+
6
+ module Upkeep
7
+ module Subscriptions
8
+ class LayeredReverseIndex
9
+ include LookupInstrumentation
10
+
11
+ LOOKUP_NOTIFICATION = LookupInstrumentation::LOOKUP_NOTIFICATION
12
+
13
+ def initialize(active_index:, persistent_index:, persistent_count:, store:, pending_index: nil)
14
+ @active_index = active_index
15
+ @persistent_index = persistent_index
16
+ @persistent_count = persistent_count
17
+ @store = store
18
+ @pending_index = pending_index
19
+ end
20
+
21
+ def entries_for_without_payload(changes)
22
+ active_entries = active_index.entries_for(changes)
23
+ return persistent_index.entries_for(changes) if active_index.count.zero?
24
+ return active_entries if active_index.covers?(persistent_subscription_count)
25
+
26
+ merge_entries(active_entries, persistent_index.entries_for(changes))
27
+ end
28
+
29
+ def entries_for_with_payload(changes, payload)
30
+ persistent_summary = persistent_index.summary
31
+ active_entries = active_index.entries_for(changes)
32
+ active_count = active_index.count
33
+ pending_entries = pending_entries_for(changes)
34
+ pending_count = pending_count_for_payload
35
+ payload[:active_entries] = active_entries.size
36
+ payload[:active_subscriptions] = active_count
37
+ payload[:pending_entries] = pending_entries.size
38
+ payload[:pending_subscriptions] = pending_count
39
+ payload[:persistent_direct_index_entries] = persistent_summary.fetch(:direct).fetch(:entries)
40
+ payload[:persistent_shape_index_entries] = persistent_summary.fetch(:shape).fetch(:entries)
41
+ payload[:persistent_direct_lookup_keys] = persistent_summary.fetch(:direct).fetch(:lookup_keys)
42
+ payload[:persistent_shape_lookup_keys] = persistent_summary.fetch(:shape).fetch(:lookup_keys)
43
+ payload[:persistent_shape_keys] = persistent_summary.fetch(:shape).fetch(:shape_keys)
44
+ payload[:persistent_shape_subscriptions] = persistent_summary.fetch(:shape).fetch(:subscriptions)
45
+
46
+ if active_count.zero?
47
+ persistent_entries = persistent_index.entries_for(changes)
48
+ payload[:mode] = persistent_entries.empty? && pending_entries.any? ? "pending_activation" : "persistent"
49
+ payload[:persistent_entries] = persistent_entries.size
50
+ apply_miss_reason(payload, active_entries: active_entries, persistent_entries: persistent_entries, pending_entries: pending_entries)
51
+ return persistent_entries
52
+ end
53
+
54
+ if active_index.covers?(persistent_subscription_count)
55
+ payload[:mode] = "active"
56
+ payload[:persistent_entries] = 0
57
+ apply_miss_reason(payload, active_entries: active_entries, persistent_entries: [], pending_entries: pending_entries)
58
+ return active_entries
59
+ end
60
+
61
+ persistent_entries = persistent_index.entries_for(changes)
62
+ payload[:mode] = "active_and_persistent"
63
+ payload[:persistent_entries] = persistent_entries.size
64
+ apply_miss_reason(payload, active_entries: active_entries, persistent_entries: persistent_entries, pending_entries: pending_entries)
65
+ merge_entries(active_entries, persistent_entries)
66
+ end
67
+
68
+ def summary
69
+ persistent = persistent_index.summary
70
+ active = active_index.summary
71
+ pending = pending_index&.summary || { lookup_keys: 0, entries: 0, subscriptions: 0 }
72
+ mode = active_index.covers?(persistent_subscription_count) ? :active : :active_and_persistent
73
+ totals = if mode == :active
74
+ active
75
+ else
76
+ {
77
+ lookup_keys: active.fetch(:lookup_keys) + persistent.fetch(:lookup_keys),
78
+ entries: active.fetch(:entries) + persistent.fetch(:entries)
79
+ }
80
+ end
81
+
82
+ {
83
+ lookup_keys: totals.fetch(:lookup_keys),
84
+ entries: totals.fetch(:entries),
85
+ mode: mode,
86
+ active: active,
87
+ pending: pending,
88
+ persistent: persistent
89
+ }
90
+ end
91
+
92
+ private
93
+
94
+ attr_reader :active_index, :persistent_index, :persistent_count, :store, :pending_index
95
+
96
+ def lookup_store
97
+ store
98
+ end
99
+
100
+ def persistent_subscription_count
101
+ persistent_count.call
102
+ end
103
+
104
+ def merge_entries(active_entries, persistent_entries)
105
+ (active_entries + persistent_entries).uniq do |entry|
106
+ [entry.subscription_id, entry.owner_id, entry.dependency_cache_key]
107
+ end
108
+ end
109
+
110
+ def pending_entries_for(changes)
111
+ pending_index ? pending_index.entries_for(changes) : []
112
+ end
113
+
114
+ def pending_count_for_payload
115
+ pending_index&.count || 0
116
+ end
117
+
118
+ def apply_miss_reason(payload, active_entries:, persistent_entries:, pending_entries:)
119
+ return if active_entries.any? || persistent_entries.any?
120
+
121
+ payload[:miss_reason] = miss_reason(pending_entries)
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/notifications"
4
+
5
+ module Upkeep
6
+ module Subscriptions
7
+ # Shared lookup instrumentation for the memory and layered reverse indexes:
8
+ # one notification name, one entries_for dispatch, one miss-reason rule.
9
+ # Including classes provide #lookup_store, #entries_for_with_payload and
10
+ # #entries_for_without_payload.
11
+ module LookupInstrumentation
12
+ LOOKUP_NOTIFICATION = "lookup_subscription_index.upkeep"
13
+
14
+ def entries_for(changes)
15
+ if ActiveSupport::Notifications.notifier.listening?(LOOKUP_NOTIFICATION)
16
+ payload = { changes: Array(changes).size, store: lookup_store }
17
+ ActiveSupport::Notifications.instrument(LOOKUP_NOTIFICATION, payload) do
18
+ entries_for_with_payload(changes, payload)
19
+ end
20
+ else
21
+ entries_for_without_payload(changes)
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ def miss_reason(pending_entries)
28
+ pending_entries.any? ? "not_activated_yet" : "no_matching_subscriber"
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,228 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+ require_relative "../dependencies"
6
+ require_relative "json_snapshot"
7
+ require_relative "reverse_index"
8
+
9
+ module Upkeep
10
+ module Subscriptions
11
+ class PersistentReverseIndex
12
+ LOOKUP_COLUMNS = [
13
+ :subscription_id,
14
+ :lookup_key_digest,
15
+ :dependency_source,
16
+ :lookup_table,
17
+ :lookup_record_id_snapshot,
18
+ :lookup_attribute,
19
+ :dependency_table,
20
+ :dependency_predicate_digest,
21
+ :dependency_metadata_snapshot,
22
+ :owner_ids_snapshot
23
+ ].freeze
24
+
25
+ SHAPE_LOOKUP_COLUMNS = [
26
+ :subscription_shape_key,
27
+ :lookup_key_digest,
28
+ :dependency_source,
29
+ :lookup_table,
30
+ :lookup_record_id_snapshot,
31
+ :lookup_attribute,
32
+ :dependency_table,
33
+ :dependency_predicate_digest,
34
+ :dependency_metadata_snapshot,
35
+ :owner_ids_snapshot
36
+ ].freeze
37
+
38
+ def initialize(reverse_index:, index_record:, shape_index_record:, subscription_record:)
39
+ @reverse_index = reverse_index
40
+ @index_record = index_record
41
+ @shape_index_record = shape_index_record
42
+ @subscription_record = subscription_record
43
+ end
44
+
45
+ def entries_for(changes)
46
+ persistent_entries_for(changes)
47
+ end
48
+
49
+ def summary
50
+ direct_lookup_key_digests = index_record.distinct.pluck(:lookup_key_digest)
51
+ shape_lookup_key_digests = shape_index_record.distinct.pluck(:lookup_key_digest)
52
+ direct_entries = index_record.count
53
+ shape_entries = shape_index_record.count
54
+ {
55
+ lookup_keys: (direct_lookup_key_digests + shape_lookup_key_digests).uniq.size,
56
+ entries: direct_entries + shape_entries,
57
+ direct: {
58
+ lookup_keys: direct_lookup_key_digests.uniq.size,
59
+ entries: direct_entries
60
+ },
61
+ shape: {
62
+ lookup_keys: shape_lookup_key_digests.uniq.size,
63
+ entries: shape_entries,
64
+ shape_keys: shape_index_record.distinct.count(:subscription_shape_key),
65
+ subscriptions: subscription_record.where.not(subscription_shape_key: nil).count
66
+ }
67
+ }
68
+ end
69
+
70
+ def self.digest(value)
71
+ Digest::SHA256.hexdigest(JSON.generate(canonical_lookup_value(value)))
72
+ end
73
+
74
+ def self.canonical_lookup_value(value)
75
+ case value
76
+ when Array
77
+ value.map { |item| canonical_lookup_value(item) }
78
+ when Hash
79
+ value.keys.sort_by(&:to_s).map do |key|
80
+ [canonical_lookup_value(key), canonical_lookup_value(value.fetch(key))]
81
+ end
82
+ when Symbol
83
+ ["symbol", value.to_s]
84
+ when String
85
+ ["string", value.encode(Encoding::UTF_8)]
86
+ else
87
+ value
88
+ end
89
+ end
90
+
91
+ private
92
+
93
+ attr_reader :reverse_index, :index_record, :shape_index_record, :subscription_record
94
+
95
+ def persistent_entries_for(changes)
96
+ lookup_keys = Array(changes).flat_map { |change| reverse_index.lookup_keys_for_change(change) }.uniq
97
+ lookup_keys_by_digest = Hash.new { |hash, digest| hash[digest] = [] }
98
+ lookup_keys.each do |lookup_key|
99
+ lookup_keys_by_digest[self.class.digest(lookup_key)] << lookup_key
100
+ end
101
+ lookup_key_digests = lookup_keys_by_digest.keys
102
+
103
+ direct_entries = index_record
104
+ .where(lookup_key_digest: lookup_key_digests)
105
+ .pluck(*LOOKUP_COLUMNS)
106
+ .flat_map { |row| entries_for_row(row, lookup_keys_by_digest) }
107
+
108
+ shape_entries = shape_entries_for(lookup_key_digests, lookup_keys_by_digest)
109
+
110
+ (direct_entries + shape_entries).uniq { |entry| [entry.subscription_id, entry.owner_id, entry.dependency_cache_key] }
111
+ end
112
+
113
+ def shape_entries_for(lookup_key_digests, lookup_keys_by_digest)
114
+ shape_rows = shape_index_record
115
+ .where(lookup_key_digest: lookup_key_digests)
116
+ .pluck(*SHAPE_LOOKUP_COLUMNS)
117
+ return [] if shape_rows.empty?
118
+
119
+ subscription_ids_by_shape_key = subscription_ids_by_shape_key(
120
+ shape_rows.map { |row| row.fetch(0) }.uniq
121
+ )
122
+ shape_rows.flat_map do |row|
123
+ entries_for_shape_row(row, lookup_keys_by_digest, subscription_ids_by_shape_key)
124
+ end
125
+ end
126
+
127
+ def entries_for_row(row, lookup_keys_by_digest)
128
+ attributes = LOOKUP_COLUMNS.zip(row).to_h
129
+ lookup_keys = lookup_keys_by_digest.fetch(attributes.fetch(:lookup_key_digest)) { return [] }
130
+ return [] unless lookup_keys.any? { |lookup_key| lookup_key_matches_row?(lookup_key, attributes) }
131
+
132
+ dependency = dependency_for_row(attributes)
133
+ dependency_cache_key = dependency.cache_key
134
+ JsonSnapshot.load(attributes.fetch(:owner_ids_snapshot)).map do |owner_id|
135
+ ReverseIndex::Entry.new(
136
+ attributes.fetch(:subscription_id),
137
+ owner_id,
138
+ dependency_cache_key,
139
+ dependency,
140
+ nil,
141
+ nil,
142
+ nil
143
+ )
144
+ end
145
+ end
146
+
147
+ def entries_for_shape_row(row, lookup_keys_by_digest, subscription_ids_by_shape_key)
148
+ attributes = SHAPE_LOOKUP_COLUMNS.zip(row).to_h
149
+ lookup_keys = lookup_keys_by_digest.fetch(attributes.fetch(:lookup_key_digest)) { return [] }
150
+ return [] unless lookup_keys.any? { |lookup_key| lookup_key_matches_row?(lookup_key, attributes) }
151
+
152
+ dependency = dependency_for_row(attributes)
153
+ dependency_cache_key = dependency.cache_key
154
+ subscription_ids = subscription_ids_by_shape_key.fetch(attributes.fetch(:subscription_shape_key), [])
155
+ subscription_ids.flat_map do |subscription_id|
156
+ JsonSnapshot.load(attributes.fetch(:owner_ids_snapshot)).map do |owner_id|
157
+ ReverseIndex::Entry.new(
158
+ subscription_id,
159
+ owner_id,
160
+ dependency_cache_key,
161
+ dependency,
162
+ nil,
163
+ nil,
164
+ nil
165
+ )
166
+ end
167
+ end
168
+ end
169
+
170
+ def lookup_key_matches_row?(lookup_key, attributes)
171
+ case lookup_key.fetch(0)
172
+ when :active_record_attribute
173
+ lookup_key.fetch(1).to_s == attributes.fetch(:lookup_table).to_s &&
174
+ lookup_key.fetch(2) == JsonSnapshot.load(attributes.fetch(:lookup_record_id_snapshot)) &&
175
+ lookup_key.fetch(3).to_s == attributes.fetch(:lookup_attribute).to_s
176
+ when :active_record_attribute_any_id
177
+ lookup_key.fetch(1).to_s == attributes.fetch(:lookup_table).to_s &&
178
+ attributes.fetch(:lookup_record_id_snapshot).nil? &&
179
+ lookup_key.fetch(2).to_s == attributes.fetch(:lookup_attribute).to_s
180
+ when :active_record_collection_column
181
+ lookup_key.fetch(1).to_s == attributes.fetch(:lookup_table).to_s &&
182
+ attributes.fetch(:lookup_record_id_snapshot).nil? &&
183
+ lookup_key.fetch(2).to_s == attributes.fetch(:lookup_attribute).to_s
184
+ else
185
+ false
186
+ end
187
+ end
188
+
189
+ def dependency_for_row(attributes)
190
+ source = attributes.fetch(:dependency_source).to_sym
191
+ case source
192
+ when :active_record_attribute
193
+ metadata_snapshot = attributes[:dependency_metadata_snapshot]
194
+ metadata = metadata_snapshot && JsonSnapshot.load(metadata_snapshot)
195
+ Dependencies::ActiveRecordAttribute.new(
196
+ table: attributes.fetch(:dependency_table),
197
+ id: attributes[:lookup_record_id_snapshot] && JsonSnapshot.load(attributes.fetch(:lookup_record_id_snapshot)),
198
+ attribute: attributes.fetch(:lookup_attribute),
199
+ scope: metadata && metadata[:scope]
200
+ )
201
+ when :active_record_collection, :active_record_query
202
+ metadata = JsonSnapshot.load(attributes.fetch(:dependency_metadata_snapshot))
203
+ dependency_class = source == :active_record_query ? Dependencies::ActiveRecordQuery : Dependencies::ActiveRecordCollection
204
+ dependency_class.new(
205
+ primary_table: attributes.fetch(:dependency_table),
206
+ table_columns: metadata.fetch(:table_columns),
207
+ coverage: metadata.fetch(:coverage),
208
+ sql: metadata.fetch(:sql),
209
+ predicates: metadata.fetch(:predicates)
210
+ )
211
+ else
212
+ raise ArgumentError, "unsupported persistent dependency source: #{source.inspect}"
213
+ end
214
+ end
215
+
216
+ def subscription_ids_by_shape_key(shape_keys)
217
+ shape_key_lookup = shape_keys.to_h { |shape_key| [shape_key, []] }
218
+ subscription_record
219
+ .where(subscription_shape_key: shape_keys)
220
+ .pluck(:subscription_shape_key, :id)
221
+ .each do |shape_key, id|
222
+ shape_key_lookup[shape_key] << id if shape_key_lookup.key?(shape_key)
223
+ end
224
+ shape_key_lookup.transform_values { |ids| ids.sort_by(&:to_s) }
225
+ end
226
+ end
227
+ end
228
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "shape"
4
+
5
+ module Upkeep
6
+ module Subscriptions
7
+ class Registrar
8
+ Registration = Data.define(:identity, :decision, :subscription, :shape)
9
+
10
+ def initialize(store:, shape_cache: ShapeCache.new)
11
+ @store = store
12
+ @shape_cache = shape_cache
13
+ end
14
+
15
+ def register(identity:, decision:, recorder:, metadata: {}, signature: nil)
16
+ shape = shape_cache.resolve(recorder: recorder, decision: decision, signature: signature)
17
+ subscription = store.register(
18
+ subscriber_id: identity.subscriber_id,
19
+ recorder: recorder,
20
+ metadata: metadata.merge(
21
+ shared_stream_names: shape.shared_stream_names,
22
+ subscription_shape_key: shape.key,
23
+ subscription_shape_cache: shape.cache_state
24
+ ).compact,
25
+ entries: shape.entries
26
+ )
27
+
28
+ Registration.new(identity, decision, subscription, shape)
29
+ end
30
+
31
+ private
32
+
33
+ attr_reader :store, :shape_cache
34
+ end
35
+ end
36
+ end