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,298 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Upkeep
4
+ module Subscriptions
5
+ class ReverseIndex
6
+ COHORT_IDENTITY_SOURCES = %w[Current.user cookie current_attribute session warden_user].freeze
7
+
8
+ Entry = Data.define(:subscription_id, :owner_id, :dependency_cache_key, :dependency, :subscriber_ids, :cohort_key) do
9
+ def represented_subscriber_ids
10
+ Array(subscriber_ids)
11
+ end
12
+
13
+ def cohort?
14
+ !!cohort_key
15
+ end
16
+ end
17
+
18
+ def initialize
19
+ @entries_by_lookup_key = Hash.new { |hash, key| hash[key] = [] }
20
+ @entry_keys_by_lookup_key = Hash.new { |hash, key| hash[key] = {} }
21
+ @lookup_keys_by_subscription_id = Hash.new { |hash, key| hash[key] = {} }
22
+ @cohort_entries_by_index_key = {}
23
+ @cohort_members_by_index_key = Hash.new { |hash, key| hash[key] = {} }
24
+ @cohort_index_keys_by_subscription_id = Hash.new { |hash, key| hash[key] = {} }
25
+ end
26
+
27
+ def index(subscription)
28
+ index_entries(entries_for_subscription(subscription))
29
+ end
30
+
31
+ def index_entries(entries, subscription: nil)
32
+ entries = entries_for_subscription_instance(entries, subscription) if subscription
33
+
34
+ entries.each do |entry|
35
+ lookup_keys_for_dependency(entry.dependency).each do |lookup_key|
36
+ if entry.cohort?
37
+ index_cohort_entry(lookup_key, entry)
38
+ else
39
+ index_direct_entry(lookup_key, entry)
40
+ end
41
+ end
42
+ end
43
+ end
44
+
45
+ def delete_subscription(subscription_id)
46
+ lookup_keys = @lookup_keys_by_subscription_id.delete(subscription_id)&.keys || []
47
+ lookup_keys.each do |lookup_key|
48
+ entries = @entries_by_lookup_key.fetch(lookup_key, nil)
49
+ next unless entries
50
+
51
+ entries.reject! { |entry| entry.subscription_id == subscription_id }
52
+ @entry_keys_by_lookup_key[lookup_key].delete_if { |entry_key, _present| entry_key.fetch(0) == subscription_id }
53
+
54
+ next unless entries.empty?
55
+
56
+ @entries_by_lookup_key.delete(lookup_key)
57
+ @entry_keys_by_lookup_key.delete(lookup_key)
58
+ end
59
+
60
+ cohort_index_keys = @cohort_index_keys_by_subscription_id.delete(subscription_id)&.keys || []
61
+ cohort_index_keys.each { |index_key| delete_cohort_subscription(index_key, subscription_id) }
62
+ end
63
+
64
+ def entries_for(changes)
65
+ raw_entries = changes
66
+ .flat_map { |change| lookup_keys_for_change(change) }
67
+ .flat_map { |lookup_key| @entries_by_lookup_key.fetch(lookup_key, []) }
68
+ .uniq { |entry| [entry.subscription_id, entry.owner_id, entry.dependency_cache_key] }
69
+
70
+ collapse_cohort_entries(raw_entries)
71
+ end
72
+
73
+ def summary
74
+ entries = @entries_by_lookup_key.values.flatten
75
+ cohort_entries = entries.select(&:cohort?)
76
+ {
77
+ lookup_keys: @entries_by_lookup_key.size,
78
+ entries: entries.size,
79
+ cohort_entries: cohort_entries.size,
80
+ represented_subscribers: entries.flat_map(&:represented_subscriber_ids).uniq.size
81
+ }
82
+ end
83
+
84
+ def entries_for_subscription(subscription)
85
+ subscription.recorder.flush_pending_dependencies if subscription.recorder.respond_to?(:flush_pending_dependencies)
86
+ cohort_key = cohort_key_for(subscription)
87
+ subscription.graph.dependency_nodes.flat_map do |node|
88
+ subscription.graph.dependency_owner_ids(node.id).map do |owner_id|
89
+ Entry.new(
90
+ subscription.id,
91
+ owner_id,
92
+ node.payload.cache_key,
93
+ node.payload,
94
+ [subscription.subscriber_id],
95
+ cohort_key
96
+ )
97
+ end
98
+ end
99
+ end
100
+
101
+ def entries_for_subscription_instance(entries, subscription)
102
+ entries.map { |entry| entry_for_subscription(entry, subscription) }
103
+ end
104
+
105
+ def lookup_keys_for_dependency(dependency)
106
+ case dependency.source
107
+ when :active_record_attribute
108
+ active_record_attribute_lookup_keys(dependency.key)
109
+ when :active_record_collection, :active_record_query
110
+ active_record_collection_lookup_keys(dependency)
111
+ else
112
+ []
113
+ end
114
+ end
115
+
116
+ def lookup_keys_for_change(change)
117
+ table = change.fetch(:table)
118
+ attributes = change.fetch(:changed_attributes, [])
119
+
120
+ keys = attributes.map do |attribute|
121
+ if change[:id]
122
+ [:active_record_attribute, table, change.fetch(:id), attribute]
123
+ else
124
+ [:active_record_attribute_any_id, table, attribute]
125
+ end
126
+ end
127
+
128
+ keys.concat(attributes.map { |attribute| [:active_record_collection_column, table, attribute] })
129
+ keys.uniq
130
+ end
131
+
132
+ private
133
+
134
+ def index_direct_entry(lookup_key, entry)
135
+ entry_key = [entry.subscription_id, entry.owner_id, entry.dependency_cache_key]
136
+ entry_keys = @entry_keys_by_lookup_key[lookup_key]
137
+ return if entry_keys.key?(entry_key)
138
+
139
+ @entries_by_lookup_key[lookup_key] << entry
140
+ entry_keys[entry_key] = true
141
+ @lookup_keys_by_subscription_id[entry.subscription_id][lookup_key] = true
142
+ end
143
+
144
+ def index_cohort_entry(lookup_key, entry)
145
+ index_key = cohort_index_key(lookup_key, entry)
146
+ members = @cohort_members_by_index_key[index_key]
147
+ subscriber_ids = (members[entry.subscription_id] || []) | entry.represented_subscriber_ids
148
+ return if members[entry.subscription_id] == subscriber_ids
149
+
150
+ existing_entry = @cohort_entries_by_index_key[index_key]
151
+ members[entry.subscription_id] = subscriber_ids
152
+ replacement_entry = existing_entry ? append_cohort_members(existing_entry, subscriber_ids) : cohort_entry_from(entry, members)
153
+
154
+ if existing_entry
155
+ replace_entry(lookup_key, existing_entry, replacement_entry)
156
+ else
157
+ @entries_by_lookup_key[lookup_key] << replacement_entry
158
+ end
159
+
160
+ @cohort_entries_by_index_key[index_key] = replacement_entry
161
+ @cohort_index_keys_by_subscription_id[entry.subscription_id][index_key] = true
162
+ end
163
+
164
+ def delete_cohort_subscription(index_key, subscription_id)
165
+ lookup_key = index_key.fetch(0)
166
+ members = @cohort_members_by_index_key.fetch(index_key, nil)
167
+ return unless members&.key?(subscription_id)
168
+
169
+ existing_entry = @cohort_entries_by_index_key.fetch(index_key)
170
+ members.delete(subscription_id)
171
+
172
+ if members.empty?
173
+ remove_entry(lookup_key, existing_entry)
174
+ @cohort_members_by_index_key.delete(index_key)
175
+ @cohort_entries_by_index_key.delete(index_key)
176
+ else
177
+ replacement_entry = cohort_entry_from(existing_entry, members)
178
+ replace_entry(lookup_key, existing_entry, replacement_entry)
179
+ @cohort_entries_by_index_key[index_key] = replacement_entry
180
+ end
181
+ end
182
+
183
+ def cohort_index_key(lookup_key, entry)
184
+ [lookup_key, entry.cohort_key, entry.owner_id, entry.dependency_cache_key]
185
+ end
186
+
187
+ def cohort_entry_from(entry, members)
188
+ Entry.new(
189
+ members.keys.sort_by(&:to_s).first,
190
+ entry.owner_id,
191
+ entry.dependency_cache_key,
192
+ entry.dependency,
193
+ members.values.flatten.uniq.sort_by(&:to_s),
194
+ entry.cohort_key
195
+ )
196
+ end
197
+
198
+ def append_cohort_members(entry, subscriber_ids)
199
+ Entry.new(
200
+ entry.subscription_id,
201
+ entry.owner_id,
202
+ entry.dependency_cache_key,
203
+ entry.dependency,
204
+ entry.represented_subscriber_ids | subscriber_ids,
205
+ entry.cohort_key
206
+ )
207
+ end
208
+
209
+ def replace_entry(lookup_key, existing_entry, replacement_entry)
210
+ entries = @entries_by_lookup_key.fetch(lookup_key)
211
+ index = entries.index(existing_entry)
212
+ entries[index] = replacement_entry if index
213
+ end
214
+
215
+ def remove_entry(lookup_key, entry)
216
+ entries = @entries_by_lookup_key.fetch(lookup_key, nil)
217
+ return unless entries
218
+
219
+ entries.delete(entry)
220
+
221
+ return unless entries.empty?
222
+
223
+ @entries_by_lookup_key.delete(lookup_key)
224
+ @entry_keys_by_lookup_key.delete(lookup_key)
225
+ end
226
+
227
+ def entry_for_subscription(entry, subscription)
228
+ Entry.new(
229
+ entry.subscription_id || subscription.id,
230
+ entry.owner_id,
231
+ entry.dependency_cache_key,
232
+ entry.dependency,
233
+ entry.subscriber_ids || [subscription.subscriber_id],
234
+ entry.cohort_key || cohort_key_for(subscription)
235
+ )
236
+ end
237
+
238
+ def collapse_cohort_entries(entries)
239
+ direct_entries = []
240
+ cohort_entries = Hash.new { |hash, key| hash[key] = [] }
241
+
242
+ entries.each do |entry|
243
+ if entry.cohort?
244
+ cohort_entries[[entry.cohort_key, entry.owner_id, entry.dependency_cache_key]] << entry
245
+ else
246
+ direct_entries << entry
247
+ end
248
+ end
249
+
250
+ direct_entries + cohort_entries.values.map { |group| collapse_cohort_entry_group(group) }
251
+ end
252
+
253
+ def collapse_cohort_entry_group(entries)
254
+ representative = entries.first
255
+ Entry.new(
256
+ representative.subscription_id,
257
+ representative.owner_id,
258
+ representative.dependency_cache_key,
259
+ representative.dependency,
260
+ entries.flat_map(&:represented_subscriber_ids).uniq.sort_by(&:to_s),
261
+ representative.cohort_key
262
+ )
263
+ end
264
+
265
+ def cohort_key_for(subscription)
266
+ return unless identity_free_subscription?(subscription)
267
+
268
+ metadata_value(subscription, :subscription_shape_key)
269
+ end
270
+
271
+ def identity_free_subscription?(subscription)
272
+ return false if subscription.graph.dependency_nodes.any? { |node| cohort_identity_dependency?(node.payload) }
273
+
274
+ true
275
+ end
276
+
277
+ def metadata_value(subscription, key)
278
+ subscription.metadata[key] || subscription.metadata[key.to_s]
279
+ end
280
+
281
+ def cohort_identity_dependency?(dependency)
282
+ Dependencies.partitioning_identity?(dependency) && COHORT_IDENTITY_SOURCES.include?(dependency.source.to_s)
283
+ end
284
+
285
+ def active_record_collection_lookup_keys(dependency)
286
+ dependency.collection_lookup_columns.map { |table, column| [:active_record_collection_column, table, column] }
287
+ end
288
+
289
+ def active_record_attribute_lookup_keys(key)
290
+ if key.fetch(:id)
291
+ [[:active_record_attribute, key.fetch(:table), key.fetch(:id), key.fetch(:attribute)]]
292
+ else
293
+ [[:active_record_attribute_any_id, key.fetch(:table), key.fetch(:attribute)]]
294
+ end
295
+ end
296
+ end
297
+ end
298
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/notifications"
4
+ require_relative "../shared_streams"
5
+ require_relative "reverse_index"
6
+
7
+ module Upkeep
8
+ module Subscriptions
9
+ class ShapeCache
10
+ NOTIFICATION = "subscription_shape.upkeep"
11
+
12
+ Shape = Data.define(:key, :entries, :shared_stream_names)
13
+ Result = Data.define(:key, :entries, :shared_stream_names, :cache_state, :cacheable, :reason, :timings)
14
+ TemplateSubscription = Data.define(:id, :subscriber_id, :recorder, :graph, :metadata)
15
+
16
+ def initialize(index_builder: ReverseIndex.new)
17
+ @index_builder = index_builder
18
+ @shapes = {}
19
+ @mutex = Mutex.new
20
+ end
21
+
22
+ def resolve(recorder:, decision:, signature: nil)
23
+ if ActiveSupport::Notifications.notifier.listening?(NOTIFICATION)
24
+ payload = {}
25
+ ActiveSupport::Notifications.instrument(NOTIFICATION, payload) do
26
+ result = resolve_without_instrumentation(recorder: recorder, decision: decision, signature: signature)
27
+ payload.merge!(
28
+ key: result.key,
29
+ cache_state: result.cache_state,
30
+ cacheable: result.cacheable,
31
+ reason: result.reason,
32
+ entries: result.entries.size,
33
+ shared_stream_names: result.shared_stream_names.size
34
+ )
35
+ payload.merge!(result.timings)
36
+ result
37
+ end
38
+ else
39
+ resolve_without_instrumentation(recorder: recorder, decision: decision, signature: signature)
40
+ end
41
+ end
42
+
43
+ def reset
44
+ @mutex.synchronize { @shapes = {} }
45
+ end
46
+
47
+ private
48
+
49
+ attr_reader :index_builder
50
+
51
+ def resolve_without_instrumentation(recorder:, decision:, signature:)
52
+ timings = {}
53
+ unless cacheable?(recorder, decision)
54
+ shape = measure(timings, :compile_ms) { compile_shape(recorder, key: nil, timings: timings) }
55
+ return Result.new(nil, shape.entries, shape.shared_stream_names, "uncached", false, cache_bypass_reason(recorder, decision), timings)
56
+ end
57
+
58
+ key = measure(timings, :key_ms) { shape_key_for(recorder, signature: signature) }
59
+ @mutex.synchronize do
60
+ if (shape = @shapes[key])
61
+ Result.new(key, shape.entries, shape.shared_stream_names, "hit", true, nil, timings)
62
+ else
63
+ shape = measure(timings, :compile_ms) { compile_shape(recorder, key: key, timings: timings) }
64
+ @shapes[key] = shape
65
+ Result.new(key, shape.entries, shape.shared_stream_names, "miss", true, nil, timings)
66
+ end
67
+ end
68
+ end
69
+
70
+ def cacheable?(recorder, decision)
71
+ decision&.anonymous && recorder.reactive?
72
+ end
73
+
74
+ def cache_bypass_reason(recorder, decision)
75
+ return "identified" unless decision&.anonymous
76
+ return "refused_boundary" unless recorder.reactive?
77
+
78
+ "uncacheable"
79
+ end
80
+
81
+ def compile_shape(recorder, key:, timings:)
82
+ subscription = TemplateSubscription.new(nil, nil, recorder, recorder.graph, { subscription_shape_key: key }.compact)
83
+ entries = measure(timings, :index_template_ms) do
84
+ index_builder.entries_for_subscription(subscription)
85
+ end
86
+ .map { |entry| template_entry(entry) }
87
+ .uniq { |entry| [entry.owner_id, entry.dependency_cache_key] }
88
+ .freeze
89
+ shared_stream_names = measure(timings, :shared_stream_names_ms) { SharedStreams.names_for_recorder(recorder) }.freeze
90
+ Shape.new(key, entries, shared_stream_names)
91
+ end
92
+
93
+ def template_entry(entry)
94
+ ReverseIndex::Entry.new(
95
+ nil,
96
+ entry.owner_id,
97
+ entry.dependency_cache_key,
98
+ entry.dependency,
99
+ nil,
100
+ entry.cohort_key
101
+ )
102
+ end
103
+
104
+ def shape_key_for(recorder, signature:)
105
+ recorder.subscription_shape(request_signature: signature).signature
106
+ end
107
+
108
+ def measure(timings, key)
109
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
110
+ yield
111
+ ensure
112
+ timings[key] = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000.0).round(3)
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "time"
5
+ require_relative "active_registry"
6
+
7
+ module Upkeep
8
+ module Subscriptions
9
+ class NotFound < KeyError; end
10
+
11
+ Subscription = Data.define(:id, :subscriber_id, :recorder, :graph, :metadata) do
12
+ def identity_signature(frame_id)
13
+ recorder.identity_signature(frame_id)
14
+ end
15
+
16
+ def replay_recipe(frame_id)
17
+ graph.node(frame_id).payload[:recipe]
18
+ end
19
+
20
+ def to_h
21
+ {
22
+ id: id,
23
+ subscriber_id: subscriber_id,
24
+ recorder: recorder.to_h,
25
+ metadata: metadata
26
+ }
27
+ end
28
+
29
+ def to_persistent_h
30
+ {
31
+ id: id,
32
+ subscriber_id: subscriber_id,
33
+ recorder: recorder.to_persistent_h,
34
+ metadata: metadata
35
+ }
36
+ end
37
+
38
+ def self.from_h(snapshot)
39
+ snapshot = Dependencies.symbolize_keys(snapshot)
40
+ recorder = Runtime::Recorder.from_h(snapshot.fetch(:recorder))
41
+
42
+ new(
43
+ snapshot.fetch(:id),
44
+ snapshot.fetch(:subscriber_id),
45
+ recorder,
46
+ recorder.graph,
47
+ snapshot.fetch(:metadata)
48
+ )
49
+ end
50
+ end
51
+
52
+ class Store
53
+ attr_reader :reverse_index
54
+
55
+ def initialize(reverse_index: ReverseIndex.new)
56
+ @active_registry = ActiveRegistry.new(reverse_index: reverse_index)
57
+ @pending_registry = ActiveRegistry.new
58
+ @pending_index_entries = {}
59
+ @reverse_index = active_registry
60
+ @next_id = 0
61
+ end
62
+
63
+ def register(subscriber_id:, recorder:, metadata: {}, entries: nil)
64
+ recorder.flush_pending_dependencies if recorder.respond_to?(:flush_pending_dependencies)
65
+ subscription = Subscription.new(
66
+ next_subscription_id,
67
+ subscriber_id,
68
+ recorder,
69
+ recorder.graph,
70
+ metadata
71
+ )
72
+
73
+ pending_registry.register(subscription, entries: entries)
74
+ @pending_index_entries[subscription.id] = entries if entries
75
+ subscription
76
+ end
77
+
78
+ def touch(id, now: Time.now)
79
+ fetch(id)
80
+ metadata = { "last_seen_at" => now.utc.iso8601 }
81
+ pending_registry.touch(id, metadata: metadata)
82
+ active_registry.touch(id, metadata: metadata)
83
+ true
84
+ end
85
+
86
+ def prune_stale!(older_than:)
87
+ stale_ids = subscriptions.filter_map do |subscription|
88
+ id = subscription.id
89
+ id if last_seen_at(subscription) && last_seen_at(subscription) < older_than
90
+ end
91
+
92
+ unregister(stale_ids)
93
+ stale_ids.size
94
+ end
95
+
96
+ def unregister(ids)
97
+ ids = Array(ids)
98
+ ids.each { |id| @pending_index_entries.delete(id) }
99
+ pending_registry.unregister(ids)
100
+ active_registry.unregister(ids)
101
+ ids.size
102
+ end
103
+
104
+ def activate(id)
105
+ return true if active_registry.fetch(id)
106
+
107
+ subscription = pending_registry.fetch(id)
108
+ return false unless subscription
109
+
110
+ entries = @pending_index_entries.delete(id)
111
+ active_registry.register(subscription, entries: entries)
112
+ pending_registry.unregister(id)
113
+ true
114
+ end
115
+
116
+ def drain
117
+ true
118
+ end
119
+
120
+ def shutdown
121
+ true
122
+ end
123
+
124
+ def fetch(id)
125
+ active_registry.fetch(id) || pending_registry.fetch(id) || raise(NotFound, id)
126
+ end
127
+
128
+ def subscriptions
129
+ active_registry.subscriptions + pending_registry.subscriptions
130
+ end
131
+
132
+ def reset
133
+ @active_registry = ActiveRegistry.new
134
+ @pending_registry = ActiveRegistry.new
135
+ @pending_index_entries = {}
136
+ @reverse_index = active_registry
137
+ @next_id = 0
138
+ end
139
+
140
+ def summary
141
+ active = active_registry.summary
142
+ pending = pending_registry.summary
143
+ {
144
+ subscriptions: subscriptions.size,
145
+ pending_subscriptions: pending_registry.count,
146
+ active_subscriptions: active_registry.count,
147
+ deferred_index_subscriptions: 0,
148
+ reverse_index: active.merge(
149
+ mode: :active,
150
+ active: active,
151
+ pending: pending,
152
+ persistent: { lookup_keys: 0, entries: 0 }
153
+ )
154
+ }
155
+ end
156
+
157
+ private
158
+
159
+ attr_reader :pending_registry, :active_registry
160
+
161
+ def last_seen_at(subscription)
162
+ value = subscription.metadata["last_seen_at"] || subscription.metadata[:last_seen_at]
163
+ Time.parse(value.to_s) if value
164
+ end
165
+
166
+ def next_subscription_id
167
+ "subscription-#{SecureRandom.uuid}"
168
+ end
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "subscriptions/reverse_index"
4
+ require_relative "subscriptions/shape"
5
+ require_relative "subscriptions/registrar"
6
+ require_relative "subscriptions/store"
7
+ require_relative "subscriptions/active_record_store"