upkeep-rails 0.1.0

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 (73) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +424 -0
  4. data/docs/architecture/ambient-inputs-roadmap.md +306 -0
  5. data/docs/architecture/herb-roadmap.md +324 -0
  6. data/docs/architecture/identity-and-sharing.md +187 -0
  7. data/docs/architecture/query-dependencies.md +230 -0
  8. data/docs/cost-model-roadmap.md +703 -0
  9. data/docs/guides/getting-started.md +282 -0
  10. data/docs/handoff-2026-05-15.md +230 -0
  11. data/docs/production_roadmap.md +372 -0
  12. data/docs/shared-warm-scale-roadmap.md +214 -0
  13. data/docs/single-subscriber-cold-roadmap.md +192 -0
  14. data/docs/testing.md +113 -0
  15. data/lib/generators/upkeep/install/install_generator.rb +90 -0
  16. data/lib/generators/upkeep/install/templates/create_upkeep_subscriptions.rb.erb +31 -0
  17. data/lib/generators/upkeep/install/templates/subscription.js +107 -0
  18. data/lib/generators/upkeep/install/templates/upkeep.rb +6 -0
  19. data/lib/upkeep/active_record_query.rb +294 -0
  20. data/lib/upkeep/capture/request.rb +150 -0
  21. data/lib/upkeep/dag/subscription_shape.rb +244 -0
  22. data/lib/upkeep/dag.rb +370 -0
  23. data/lib/upkeep/delivery/action_cable_adapter.rb +43 -0
  24. data/lib/upkeep/delivery/async_dispatcher.rb +102 -0
  25. data/lib/upkeep/delivery/broadcast_transport.rb +89 -0
  26. data/lib/upkeep/delivery/transport.rb +194 -0
  27. data/lib/upkeep/delivery/turbo_streams.rb +275 -0
  28. data/lib/upkeep/delivery.rb +7 -0
  29. data/lib/upkeep/dependencies.rb +466 -0
  30. data/lib/upkeep/herb/developer_report.rb +116 -0
  31. data/lib/upkeep/herb/manifest_cache.rb +83 -0
  32. data/lib/upkeep/herb/manifest_diff.rb +183 -0
  33. data/lib/upkeep/herb/source_instrumenter.rb +84 -0
  34. data/lib/upkeep/herb/template_manifest.rb +377 -0
  35. data/lib/upkeep/invalidation/collection_append.rb +84 -0
  36. data/lib/upkeep/invalidation/collection_member_replace.rb +78 -0
  37. data/lib/upkeep/invalidation/collection_prepend.rb +84 -0
  38. data/lib/upkeep/invalidation/collection_remove.rb +57 -0
  39. data/lib/upkeep/invalidation/planner.rb +341 -0
  40. data/lib/upkeep/invalidation.rb +7 -0
  41. data/lib/upkeep/rails/action_view_capture.rb +765 -0
  42. data/lib/upkeep/rails/cable/channel.rb +108 -0
  43. data/lib/upkeep/rails/cable/subscriber_identity.rb +214 -0
  44. data/lib/upkeep/rails/cable.rb +4 -0
  45. data/lib/upkeep/rails/client_subscription.rb +37 -0
  46. data/lib/upkeep/rails/configuration.rb +57 -0
  47. data/lib/upkeep/rails/controller_runtime.rb +137 -0
  48. data/lib/upkeep/rails/install.rb +28 -0
  49. data/lib/upkeep/rails/railtie.rb +36 -0
  50. data/lib/upkeep/rails/replay.rb +176 -0
  51. data/lib/upkeep/rails/testing.rb +36 -0
  52. data/lib/upkeep/rails.rb +276 -0
  53. data/lib/upkeep/replay.rb +408 -0
  54. data/lib/upkeep/runtime.rb +1075 -0
  55. data/lib/upkeep/shared_streams.rb +72 -0
  56. data/lib/upkeep/subscriptions/active_record_store.rb +292 -0
  57. data/lib/upkeep/subscriptions/active_record_subscription_persistence.rb +291 -0
  58. data/lib/upkeep/subscriptions/active_registry.rb +93 -0
  59. data/lib/upkeep/subscriptions/async_durable_writer.rb +136 -0
  60. data/lib/upkeep/subscriptions/json_snapshot.rb +98 -0
  61. data/lib/upkeep/subscriptions/layered_reverse_index.rb +122 -0
  62. data/lib/upkeep/subscriptions/persistent_reverse_index.rb +144 -0
  63. data/lib/upkeep/subscriptions/registrar.rb +36 -0
  64. data/lib/upkeep/subscriptions/reverse_index.rb +294 -0
  65. data/lib/upkeep/subscriptions/shape.rb +116 -0
  66. data/lib/upkeep/subscriptions/store.rb +159 -0
  67. data/lib/upkeep/subscriptions.rb +7 -0
  68. data/lib/upkeep/targeting.rb +135 -0
  69. data/lib/upkeep/version.rb +5 -0
  70. data/lib/upkeep-rails.rb +3 -0
  71. data/lib/upkeep.rb +14 -0
  72. data/upkeep-rails.gemspec +53 -0
  73. metadata +296 -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(&:identity?)
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,292 @@
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
+
25
+ class SubscriptionRecord < ActiveRecord::Base
26
+ self.table_name = "upkeep_subscriptions"
27
+ self.primary_key = "id"
28
+
29
+ has_many :index_entries,
30
+ class_name: "Upkeep::Subscriptions::ActiveRecordStore::IndexEntryRecord",
31
+ foreign_key: "subscription_id",
32
+ dependent: :delete_all
33
+ end
34
+
35
+ class IndexEntryRecord < ActiveRecord::Base
36
+ self.table_name = "upkeep_subscription_index_entries"
37
+
38
+ belongs_to :subscription,
39
+ class_name: "Upkeep::Subscriptions::ActiveRecordStore::SubscriptionRecord",
40
+ foreign_key: "subscription_id"
41
+ end
42
+
43
+ attr_reader :reverse_index
44
+
45
+ DeferredIndexWrite = Data.define(:subscription, :entries)
46
+
47
+ def initialize(subscription_record: SubscriptionRecord, index_record: IndexEntryRecord)
48
+ @subscription_record = subscription_record
49
+ @index_record = index_record
50
+ @index_builder = ReverseIndex.new
51
+ @pending_registry = ActiveRegistry.new
52
+ @active_registry = ActiveRegistry.new
53
+ @deferred_index_writes = {}
54
+ @deferred_index_mutex = Mutex.new
55
+ @persistence = ActiveRecordSubscriptionPersistence.new(
56
+ subscription_record: subscription_record,
57
+ index_record: index_record,
58
+ index_builder: index_builder
59
+ )
60
+ persistent_index = PersistentReverseIndex.new(
61
+ reverse_index: index_builder,
62
+ index_record: index_record
63
+ )
64
+ @reverse_index = LayeredReverseIndex.new(
65
+ active_index: active_registry,
66
+ persistent_index: persistent_index,
67
+ persistent_count: -> { persistence.count },
68
+ store: "active_record",
69
+ pending_index: pending_registry
70
+ )
71
+ @durable_writer = AsyncDurableWriter.new { |jobs| persistence.persist_jobs(jobs) }
72
+ end
73
+
74
+ def self.available?(connect: false)
75
+ return false unless ActiveRecord::Base.connected? || connect
76
+
77
+ connection = ActiveRecord::Base.connection
78
+ connection.data_source_exists?("upkeep_subscriptions") &&
79
+ connection.data_source_exists?("upkeep_subscription_index_entries")
80
+ rescue ActiveRecord::ConnectionNotEstablished, ActiveRecord::NoDatabaseError, ActiveRecord::StatementInvalid
81
+ false
82
+ end
83
+
84
+ def register(subscriber_id:, recorder:, metadata: {}, entries: nil)
85
+ if ActiveSupport::Notifications.notifier.listening?(REGISTER_NOTIFICATION)
86
+ payload = { store: "active_record" }
87
+ ActiveSupport::Notifications.instrument(REGISTER_NOTIFICATION, payload) do
88
+ register_subscription(subscriber_id, recorder, metadata, entries: entries, payload: payload)
89
+ end
90
+ else
91
+ register_subscription(subscriber_id, recorder, metadata, entries: entries)
92
+ end
93
+ end
94
+
95
+ def drain = durable_writer.drain
96
+
97
+ def shutdown
98
+ clear_deferred_index_writes
99
+ durable_writer.shutdown
100
+ end
101
+
102
+ def activate(id)
103
+ if ActiveSupport::Notifications.notifier.listening?(ACTIVATE_NOTIFICATION)
104
+ payload = { store: "active_record", subscription_id: id }
105
+ ActiveSupport::Notifications.instrument(ACTIVATE_NOTIFICATION, payload) do
106
+ activate_subscription(id, payload: payload)
107
+ end
108
+ else
109
+ activate_subscription(id)
110
+ end
111
+ end
112
+
113
+ def touch(id, now: Time.now)
114
+ metadata = { "last_seen_at" => now.utc.iso8601 }
115
+ pending_registry.touch(id, metadata: metadata)
116
+ active_registry.touch(id, metadata: metadata)
117
+ activate(id)
118
+ durable_writer.drain
119
+ persistence.touch(id, metadata: metadata, now: now)
120
+ end
121
+
122
+ def unregister(ids)
123
+ ids = Array(ids)
124
+ pending_registry.unregister(ids)
125
+ active_registry.unregister(ids)
126
+ delete_deferred_index_writes(ids)
127
+ persisted_ids = durable_writer.cancel(ids)
128
+ persistence.delete(persisted_ids)
129
+ ids.size
130
+ end
131
+
132
+ def prune_stale!(older_than:)
133
+ durable_writer.drain
134
+ stale_ids = persistence.prune_stale!(older_than: older_than)
135
+ active_registry.unregister(stale_ids)
136
+ stale_ids.size
137
+ end
138
+
139
+ def fetch(id)
140
+ active_registry.fetch(id) || pending_registry.fetch(id) || persistence.fetch(id)
141
+ end
142
+
143
+ def subscriptions
144
+ persistent_count = persistence.count
145
+ in_memory_subscriptions = (active_registry.subscriptions + pending_registry.subscriptions).to_h do |subscription|
146
+ [subscription.id, subscription]
147
+ end
148
+ return in_memory_subscriptions.values if in_memory_subscriptions.size >= persistent_count
149
+
150
+ seen_ids = {}
151
+ persisted = persistence.subscriptions.map do |subscription|
152
+ seen_ids[subscription.id] = true
153
+ in_memory_subscriptions.fetch(subscription.id, subscription)
154
+ end
155
+ persisted + in_memory_subscriptions.values.reject { |subscription| seen_ids[subscription.id] }
156
+ end
157
+
158
+ def reset
159
+ clear_deferred_index_writes
160
+ durable_writer.drain
161
+ pending_registry.reset
162
+ active_registry.reset
163
+ persistence.reset
164
+ end
165
+
166
+ def summary
167
+ persistent_count = persistence.count
168
+ pending_count = pending_registry.count
169
+ active_count = active_registry.count
170
+ {
171
+ subscriptions: [persistent_count, active_count + pending_count].max,
172
+ persistent_subscriptions: persistent_count,
173
+ pending_subscriptions: pending_count,
174
+ active_subscriptions: active_count,
175
+ deferred_index_subscriptions: deferred_index_count,
176
+ reverse_index: reverse_index.summary
177
+ }
178
+ end
179
+
180
+ private
181
+
182
+ attr_reader :subscription_record, :index_record, :index_builder, :pending_registry, :active_registry, :persistence, :durable_writer
183
+
184
+ def register_subscription(subscriber_id, recorder, metadata, entries: nil, payload: nil)
185
+ recorder.flush_pending_dependencies if recorder.respond_to?(:flush_pending_dependencies)
186
+ subscription = Subscription.new(
187
+ next_subscription_id,
188
+ subscriber_id,
189
+ recorder,
190
+ recorder.graph,
191
+ metadata
192
+ )
193
+
194
+ entries = unique_entries(materialize_entries(subscription, entries))
195
+ if payload
196
+ payload[:subscription_id] = subscription.id
197
+ payload[:dependency_entries] = entries.size
198
+ payload[:mode] = "pending_activation"
199
+ payload[:durability] = DURABILITY_MODE
200
+ payload[:index_durability] = INDEX_DURABILITY
201
+ end
202
+
203
+ pending_registry.register(subscription, entries: entries)
204
+ durable_writer.enqueue(subscription, entries: entries, operation: :persist_subscription)
205
+ defer_index_write(subscription, entries)
206
+ subscription
207
+ end
208
+
209
+ def activate_subscription(id, payload: nil)
210
+ subscription, entries, source = activation_index_write(id)
211
+ unless subscription
212
+ payload[:activated] = false if payload
213
+ payload[:miss_reason] = "no_subscription" if payload
214
+ return false
215
+ end
216
+
217
+ unless source == :active
218
+ active_registry.register(subscription, entries: entries)
219
+ pending_registry.unregister(id)
220
+ durable_writer.enqueue(subscription, entries: entries, operation: :persist_index)
221
+ end
222
+
223
+ if payload
224
+ payload[:activated] = true
225
+ payload[:activation_source] = source
226
+ payload[:dependency_entries] = entries.size
227
+ payload[:active_subscriptions] = active_registry.count
228
+ payload[:pending_subscriptions] = pending_registry.count
229
+ end
230
+
231
+ true
232
+ end
233
+
234
+ def defer_index_write(subscription, entries)
235
+ @deferred_index_mutex.synchronize do
236
+ @deferred_index_writes[subscription.id] = DeferredIndexWrite.new(subscription, entries)
237
+ end
238
+ end
239
+
240
+ def activation_index_write(id)
241
+ deferred_write = take_deferred_index_write(id)
242
+ if deferred_write
243
+ subscription = pending_registry.fetch(id) || active_registry.fetch(id) || deferred_write.subscription
244
+ return [subscription, deferred_write.entries, :pending]
245
+ end
246
+
247
+ if (subscription = active_registry.fetch(id))
248
+ [subscription, [], :active]
249
+ elsif (subscription = pending_registry.fetch(id))
250
+ [subscription, unique_entries(index_builder.entries_for_subscription(subscription)), :pending]
251
+ else
252
+ subscription, entries = persistence.fetch_with_index_entries(id)
253
+ [subscription, entries, :persistent]
254
+ end
255
+ rescue ActiveRecord::RecordNotFound
256
+ [nil, nil, nil]
257
+ end
258
+
259
+ def take_deferred_index_write(id)
260
+ @deferred_index_mutex.synchronize { @deferred_index_writes.delete(id) }
261
+ end
262
+
263
+ def delete_deferred_index_writes(ids)
264
+ @deferred_index_mutex.synchronize { ids.each { |id| @deferred_index_writes.delete(id) } }
265
+ end
266
+
267
+ def clear_deferred_index_writes
268
+ @deferred_index_mutex.synchronize { @deferred_index_writes.clear }
269
+ end
270
+
271
+ def deferred_index_count
272
+ @deferred_index_mutex.synchronize { @deferred_index_writes.size }
273
+ end
274
+
275
+ def unique_entries(entries)
276
+ entries.uniq { |entry| [entry.owner_id, entry.dependency_cache_key] }
277
+ end
278
+
279
+ def materialize_entries(subscription, entries)
280
+ if entries
281
+ index_builder.entries_for_subscription_instance(entries, subscription)
282
+ else
283
+ index_builder.entries_for_subscription(subscription)
284
+ end
285
+ end
286
+
287
+ def next_subscription_id
288
+ "subscription-#{SecureRandom.uuid}"
289
+ end
290
+ end
291
+ end
292
+ end
@@ -0,0 +1,291 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+ require "active_support/notifications"
5
+ require_relative "json_snapshot"
6
+ require_relative "persistent_reverse_index"
7
+ require_relative "store"
8
+
9
+ module Upkeep
10
+ module Subscriptions
11
+ class ActiveRecordSubscriptionPersistence
12
+ PERSIST_NOTIFICATION = "persist_subscription_store.upkeep"
13
+ INDEX_ENTRIES_SNAPSHOT_KEY = "__upkeep_index_entries"
14
+
15
+ def initialize(subscription_record:, index_record:, index_builder:)
16
+ @subscription_record = subscription_record
17
+ @index_record = index_record
18
+ @index_builder = index_builder
19
+ @count_mutex = Mutex.new
20
+ @count_cache = nil
21
+ end
22
+
23
+ def persist_jobs(jobs)
24
+ if ActiveSupport::Notifications.notifier.listening?(PERSIST_NOTIFICATION)
25
+ payload = {
26
+ store: "active_record",
27
+ jobs: jobs.size,
28
+ subscriptions: jobs.count { |job| persist_subscription?(job) },
29
+ index_jobs: jobs.count { |job| persist_index?(job) },
30
+ dependency_entries: jobs.sum { |job| persist_index?(job) ? job.entries.size : 0 },
31
+ pending_index_entries: jobs.sum { |job| persist_subscription?(job) ? job.entries.size : 0 },
32
+ operations: operation_counts(jobs)
33
+ }
34
+ ActiveSupport::Notifications.instrument(PERSIST_NOTIFICATION, payload) do
35
+ result = persist_jobs_without_instrumentation(jobs)
36
+ payload[:subscription_rows] = result.fetch(:subscription_rows)
37
+ payload[:index_rows] = result.fetch(:index_rows)
38
+ end
39
+ else
40
+ persist_jobs_without_instrumentation(jobs)
41
+ end
42
+ end
43
+
44
+ def touch(id, metadata:, now:)
45
+ subscription_record.where(id: id).find_each do |record|
46
+ record.update_columns(
47
+ metadata: record.metadata.to_h.merge(metadata),
48
+ updated_at: now
49
+ )
50
+ end
51
+ end
52
+
53
+ def prune_stale!(older_than:)
54
+ stale_ids = subscription_record.where(subscription_record.arel_table[:updated_at].lt(older_than)).pluck(:id)
55
+ return [] if stale_ids.empty?
56
+
57
+ delete(stale_ids)
58
+ stale_ids
59
+ end
60
+
61
+ def delete(ids)
62
+ ids = Array(ids)
63
+ return if ids.empty?
64
+
65
+ ActiveRecord::Base.connection_pool.with_connection do
66
+ ActiveRecord::Base.transaction do
67
+ index_record.where(subscription_id: ids).delete_all
68
+ deleted = subscription_record.where(id: ids).delete_all
69
+ decrement_count_cache(deleted)
70
+ end
71
+ end
72
+ end
73
+
74
+ def fetch(id)
75
+ record = subscription_record.find(id)
76
+ subscription_with_metadata(record)
77
+ end
78
+
79
+ def fetch_with_index_entries(id)
80
+ record = subscription_record.find(id)
81
+ [subscription_with_metadata(record), index_entries_from_snapshot(record.recorder_snapshot)]
82
+ end
83
+
84
+ def subscriptions
85
+ subscription_record.order(:created_at, :id).map { |record| subscription_with_metadata(record) }
86
+ end
87
+
88
+ def reset
89
+ index_record.delete_all
90
+ subscription_record.delete_all
91
+ write_count_cache(0)
92
+ end
93
+
94
+ def count
95
+ @count_mutex.synchronize do
96
+ @count_cache ||= subscription_record.count
97
+ end
98
+ end
99
+
100
+ private
101
+
102
+ attr_reader :subscription_record, :index_record, :index_builder
103
+
104
+ def persist_jobs_without_instrumentation(jobs)
105
+ subscription_jobs = jobs.select { |job| persist_subscription?(job) }
106
+ index_jobs = jobs.select { |job| persist_index?(job) }
107
+
108
+ result = ActiveRecord::Base.connection_pool.with_connection do
109
+ ActiveRecord::Base.transaction do
110
+ {
111
+ subscription_rows: persist_subscription_records(subscription_jobs),
112
+ index_rows: index_subscriptions(index_jobs)
113
+ }
114
+ end
115
+ end
116
+ increment_count_cache(result.fetch(:subscription_rows))
117
+ result
118
+ end
119
+
120
+ def persist_subscription_records(jobs)
121
+ now = Time.now
122
+ rows = jobs.map do |job|
123
+ subscription = job.subscription
124
+ {
125
+ id: subscription.id,
126
+ subscriber_id: subscription.subscriber_id,
127
+ metadata: subscription.metadata,
128
+ recorder_snapshot: dump(persistent_snapshot_for(job)),
129
+ created_at: now,
130
+ updated_at: now
131
+ }
132
+ end
133
+
134
+ subscription_record.insert_all!(rows) if rows.any?
135
+ rows.size
136
+ end
137
+
138
+ def index_subscriptions(jobs)
139
+ return 0 if jobs.empty?
140
+
141
+ now = Time.now
142
+ grouped_rows = {}
143
+
144
+ jobs.each do |job|
145
+ job.entries.each do |entry|
146
+ index_builder.lookup_keys_for_dependency(entry.dependency).each do |lookup_key|
147
+ lookup_attributes = typed_lookup_attributes(entry.dependency, lookup_key)
148
+ key = [job.subscription.id, lookup_attributes]
149
+ row = grouped_rows[key] ||= {
150
+ subscription_id: job.subscription.id,
151
+ lookup_key_digest: PersistentReverseIndex.digest(lookup_key),
152
+ owner_ids: [],
153
+ created_at: now,
154
+ updated_at: now
155
+ }.merge(lookup_attributes)
156
+ row.fetch(:owner_ids) << entry.owner_id
157
+ end
158
+ end
159
+ end
160
+
161
+ rows = grouped_rows.values.map do |row|
162
+ row.merge(owner_ids_snapshot: dump(row.delete(:owner_ids).uniq))
163
+ end
164
+
165
+ index_record.where(subscription_id: jobs.map { |job| job.subscription.id }.uniq).delete_all
166
+ index_record.insert_all!(rows) if rows.any?
167
+ rows.size
168
+ end
169
+
170
+ def typed_lookup_attributes(dependency, lookup_key)
171
+ lookup_type = lookup_key.fetch(0)
172
+ source = dependency.source.to_s
173
+ dependency_key = dependency.key
174
+
175
+ case lookup_type
176
+ when :active_record_attribute
177
+ _type, table, record_id, attribute = lookup_key
178
+ {
179
+ dependency_source: source,
180
+ lookup_table: table.to_s,
181
+ lookup_record_id_snapshot: dump(record_id),
182
+ lookup_attribute: attribute.to_s,
183
+ dependency_table: dependency_key.fetch(:table).to_s,
184
+ dependency_predicate_digest: nil,
185
+ dependency_metadata_snapshot: nil
186
+ }
187
+ when :active_record_attribute_any_id
188
+ _type, table, attribute = lookup_key
189
+ {
190
+ dependency_source: source,
191
+ lookup_table: table.to_s,
192
+ lookup_record_id_snapshot: nil,
193
+ lookup_attribute: attribute.to_s,
194
+ dependency_table: dependency_key.fetch(:table).to_s,
195
+ dependency_predicate_digest: nil,
196
+ dependency_metadata_snapshot: nil
197
+ }
198
+ when :active_record_collection_column
199
+ _type, table, attribute = lookup_key
200
+ {
201
+ dependency_source: source,
202
+ lookup_table: table.to_s,
203
+ lookup_record_id_snapshot: nil,
204
+ lookup_attribute: attribute.to_s,
205
+ dependency_table: dependency_key.fetch(:table).to_s,
206
+ dependency_predicate_digest: dependency_key.fetch(:predicate_digest).to_s,
207
+ dependency_metadata_snapshot: dump(dependency.metadata)
208
+ }
209
+ else
210
+ raise ArgumentError, "unsupported persistent lookup key: #{lookup_key.inspect}"
211
+ end
212
+ end
213
+
214
+ def subscription_with_metadata(record)
215
+ subscription = Subscription.from_h(load(record.recorder_snapshot))
216
+ Subscription.new(
217
+ subscription.id,
218
+ subscription.subscriber_id,
219
+ subscription.recorder,
220
+ subscription.graph,
221
+ subscription.metadata.merge(record.metadata.to_h)
222
+ )
223
+ end
224
+
225
+ def persistent_snapshot_for(job)
226
+ subscription = job.subscription
227
+ subscription.to_persistent_h.merge(
228
+ INDEX_ENTRIES_SNAPSHOT_KEY => index_entries_snapshot(job.entries)
229
+ )
230
+ end
231
+
232
+ def index_entries_snapshot(entries)
233
+ entries.map do |entry|
234
+ {
235
+ subscription_id: entry.subscription_id,
236
+ owner_id: entry.owner_id,
237
+ dependency: entry.dependency.to_h
238
+ }
239
+ end
240
+ end
241
+
242
+ def index_entries_from_snapshot(recorder_snapshot)
243
+ snapshot = load(recorder_snapshot)
244
+ Array(snapshot.fetch(INDEX_ENTRIES_SNAPSHOT_KEY)).map do |entry_snapshot|
245
+ entry_snapshot = Dependencies.symbolize_keys(entry_snapshot)
246
+ dependency = Dependencies.from_h(entry_snapshot.fetch(:dependency))
247
+ ReverseIndex::Entry.new(
248
+ entry_snapshot.fetch(:subscription_id),
249
+ entry_snapshot.fetch(:owner_id),
250
+ dependency.cache_key,
251
+ dependency,
252
+ nil,
253
+ nil
254
+ )
255
+ end
256
+ end
257
+
258
+ def dump(value)
259
+ JsonSnapshot.dump(value)
260
+ end
261
+
262
+ def load(snapshot)
263
+ JsonSnapshot.load(snapshot)
264
+ end
265
+
266
+ def increment_count_cache(value)
267
+ @count_mutex.synchronize { @count_cache += value if @count_cache }
268
+ end
269
+
270
+ def decrement_count_cache(value)
271
+ @count_mutex.synchronize { @count_cache -= value if @count_cache }
272
+ end
273
+
274
+ def write_count_cache(value)
275
+ @count_mutex.synchronize { @count_cache = value }
276
+ end
277
+
278
+ def persist_subscription?(job)
279
+ job.operation == :persist_subscription
280
+ end
281
+
282
+ def persist_index?(job)
283
+ job.operation == :persist_index
284
+ end
285
+
286
+ def operation_counts(jobs)
287
+ jobs.each_with_object(Hash.new(0)) { |job, counts| counts[job.operation.to_s] += 1 }.to_h
288
+ end
289
+ end
290
+ end
291
+ end