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,411 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+ require "active_support/notifications"
5
+ require "logger"
6
+ require_relative "json_snapshot"
7
+ require_relative "persistent_reverse_index"
8
+ require_relative "store"
9
+
10
+ module Upkeep
11
+ module Subscriptions
12
+ class ActiveRecordSubscriptionPersistence
13
+ PERSIST_NOTIFICATION = "persist_subscription_store.upkeep"
14
+ INDEX_ENTRIES_SNAPSHOT_KEY = "__upkeep_index_entries"
15
+ Job = Data.define(:subscription, :entries, :operation)
16
+
17
+ def initialize(subscription_record:, index_record:, shape_index_record:, index_builder:)
18
+ @subscription_record = subscription_record
19
+ @index_record = index_record
20
+ @shape_index_record = shape_index_record
21
+ @index_builder = index_builder
22
+ @count_mutex = Mutex.new
23
+ @count_cache = nil
24
+ end
25
+
26
+ def persist_jobs(jobs)
27
+ if ActiveSupport::Notifications.notifier.listening?(PERSIST_NOTIFICATION)
28
+ payload = {
29
+ store: "active_record",
30
+ jobs: jobs.size,
31
+ subscriptions: jobs.count { |job| persist_subscription?(job) },
32
+ index_jobs: jobs.count { |job| persist_index?(job) },
33
+ dependency_entries: jobs.sum { |job| persist_index?(job) ? job.entries.size : 0 },
34
+ pending_index_entries: jobs.sum { |job| persist_subscription?(job) ? job.entries.size : 0 },
35
+ operations: operation_counts(jobs)
36
+ }
37
+ ActiveSupport::Notifications.instrument(PERSIST_NOTIFICATION, payload) do
38
+ result = persist_jobs_without_instrumentation(jobs)
39
+ payload[:subscription_rows] = result.fetch(:subscription_rows)
40
+ payload[:index_rows] = result.fetch(:index_rows)
41
+ payload[:direct_index_rows] = result.fetch(:direct_index_rows)
42
+ payload[:shape_index_rows] = result.fetch(:shape_index_rows)
43
+ end
44
+ else
45
+ persist_jobs_without_instrumentation(jobs)
46
+ end
47
+ end
48
+
49
+ def touch(id, metadata:, now:)
50
+ subscription_record.where(id: id).find_each do |record|
51
+ record.update_columns(
52
+ metadata: record.metadata.to_h.merge(metadata),
53
+ updated_at: now
54
+ )
55
+ end
56
+ end
57
+
58
+ def prune_stale!(older_than:, limit: nil)
59
+ stale = subscription_record.where(subscription_record.arel_table[:updated_at].lt(older_than))
60
+ stale = stale.order(:updated_at).limit(limit) if limit
61
+ stale_ids = stale.pluck(:id)
62
+ return [] if stale_ids.empty?
63
+
64
+ delete(stale_ids)
65
+ stale_ids
66
+ end
67
+
68
+ def delete(ids)
69
+ ids = Array(ids)
70
+ return if ids.empty?
71
+
72
+ silence_active_record_logging do
73
+ ActiveRecord::Base.connection_pool.with_connection do
74
+ ActiveRecord::Base.transaction do
75
+ shape_keys = shape_keys_for_subscriptions(ids)
76
+ index_record.where(subscription_id: ids).delete_all
77
+ deleted = subscription_record.where(id: ids).delete_all
78
+ delete_orphaned_shape_index_rows(shape_keys)
79
+ decrement_count_cache(deleted)
80
+ end
81
+ end
82
+ end
83
+ end
84
+
85
+ def fetch(id)
86
+ record = subscription_record.find(id)
87
+ subscription_with_metadata(record)
88
+ end
89
+
90
+ def fetch_with_index_entries(id)
91
+ record = subscription_record.find(id)
92
+ [subscription_with_metadata(record), index_entries_from_snapshot(record.recorder_snapshot)]
93
+ end
94
+
95
+ def subscriptions
96
+ subscription_record.order(:created_at, :id).map { |record| subscription_with_metadata(record) }
97
+ end
98
+
99
+ def reset
100
+ index_record.delete_all
101
+ shape_index_record.delete_all
102
+ subscription_record.delete_all
103
+ write_count_cache(0)
104
+ end
105
+
106
+ def count
107
+ @count_mutex.synchronize do
108
+ @count_cache ||= subscription_record.count
109
+ end
110
+ end
111
+
112
+ private
113
+
114
+ attr_reader :subscription_record, :index_record, :shape_index_record, :index_builder
115
+
116
+ def persist_jobs_without_instrumentation(jobs)
117
+ subscription_jobs = jobs.select { |job| persist_subscription?(job) }
118
+ index_jobs = jobs.select { |job| persist_index?(job) }
119
+
120
+ result = silence_active_record_logging do
121
+ ActiveRecord::Base.connection_pool.with_connection do
122
+ ActiveRecord::Base.transaction do
123
+ subscription_rows = persist_subscription_records(subscription_jobs)
124
+ index_result = index_subscriptions(index_jobs)
125
+ {
126
+ subscription_rows: subscription_rows,
127
+ index_rows: index_result.fetch(:index_rows),
128
+ direct_index_rows: index_result.fetch(:direct_index_rows),
129
+ shape_index_rows: index_result.fetch(:shape_index_rows)
130
+ }
131
+ end
132
+ end
133
+ end
134
+ increment_count_cache(result.fetch(:subscription_rows))
135
+ result
136
+ end
137
+
138
+ def persist_subscription_records(jobs)
139
+ now = Time.now
140
+ rows = jobs.map do |job|
141
+ subscription = job.subscription
142
+ {
143
+ id: subscription.id,
144
+ subscriber_id: subscription.subscriber_id,
145
+ metadata: metadata_without_shape_key(subscription.metadata),
146
+ subscription_shape_key: shape_key_for_metadata(subscription.metadata),
147
+ recorder_snapshot: dump(persistent_snapshot_for(job)),
148
+ created_at: now,
149
+ updated_at: now
150
+ }
151
+ end
152
+
153
+ subscription_record.insert_all!(rows) if rows.any?
154
+ rows.size
155
+ end
156
+
157
+ def index_subscriptions(jobs)
158
+ return index_result if jobs.empty?
159
+
160
+ now = Time.now
161
+ subscription_ids = jobs.map { |job| job.subscription.id }.uniq
162
+ shape_jobs, direct_jobs = jobs.partition { |job| shape_indexable?(job) }
163
+
164
+ index_record.where(subscription_id: subscription_ids).delete_all
165
+
166
+ direct_rows = index_direct_subscriptions(direct_jobs, now: now)
167
+ shape_rows = index_shape_subscriptions(shape_jobs, now: now)
168
+ index_result(direct_index_rows: direct_rows, shape_index_rows: shape_rows)
169
+ end
170
+
171
+ def index_direct_subscriptions(jobs, now:)
172
+ return 0 if jobs.empty?
173
+
174
+ grouped_rows = {}
175
+
176
+ jobs.each do |job|
177
+ job.entries.each do |entry|
178
+ index_builder.lookup_keys_for_dependency(entry.dependency).each do |lookup_key|
179
+ lookup_attributes = typed_lookup_attributes(entry.dependency, lookup_key)
180
+ key = [job.subscription.id, lookup_attributes]
181
+ row = grouped_rows[key] ||= {
182
+ subscription_id: job.subscription.id,
183
+ lookup_key_digest: PersistentReverseIndex.digest(lookup_key),
184
+ owner_ids: [],
185
+ created_at: now,
186
+ updated_at: now
187
+ }.merge(lookup_attributes)
188
+ row.fetch(:owner_ids) << entry.owner_id
189
+ end
190
+ end
191
+ end
192
+
193
+ rows = grouped_rows.values.map do |row|
194
+ row.merge(owner_ids_snapshot: dump(row.delete(:owner_ids).uniq))
195
+ end
196
+
197
+ index_record.insert_all!(rows) if rows.any?
198
+ rows.size
199
+ end
200
+
201
+ def index_shape_subscriptions(jobs, now:)
202
+ return 0 if jobs.empty?
203
+
204
+ grouped_rows = {}
205
+ shape_keys = jobs.map { |job| shape_index_key(job) }.compact.uniq
206
+
207
+ jobs.each do |job|
208
+ shape_key = shape_index_key(job)
209
+ job.entries.each do |entry|
210
+ index_builder.lookup_keys_for_dependency(entry.dependency).each do |lookup_key|
211
+ lookup_attributes = typed_lookup_attributes(entry.dependency, lookup_key)
212
+ key = [shape_key, lookup_attributes]
213
+ row = grouped_rows[key] ||= {
214
+ subscription_shape_key: shape_key,
215
+ lookup_key_digest: PersistentReverseIndex.digest(lookup_key),
216
+ owner_ids: [],
217
+ created_at: now,
218
+ updated_at: now
219
+ }.merge(lookup_attributes)
220
+ row.fetch(:owner_ids) << entry.owner_id
221
+ end
222
+ end
223
+ end
224
+
225
+ rows = grouped_rows.values.map do |row|
226
+ row.merge(owner_ids_snapshot: dump(row.delete(:owner_ids).uniq))
227
+ end
228
+
229
+ shape_index_record.where(subscription_shape_key: shape_keys).delete_all
230
+ shape_index_record.insert_all!(rows) if rows.any?
231
+ rows.size
232
+ end
233
+
234
+ def typed_lookup_attributes(dependency, lookup_key)
235
+ lookup_type = lookup_key.fetch(0)
236
+ source = dependency.source.to_s
237
+ dependency_key = dependency.key
238
+
239
+ case lookup_type
240
+ when :active_record_attribute
241
+ _type, table, record_id, attribute = lookup_key
242
+ {
243
+ dependency_source: source,
244
+ lookup_table: table.to_s,
245
+ lookup_record_id_snapshot: dump(record_id),
246
+ lookup_attribute: attribute.to_s,
247
+ dependency_table: dependency_key.fetch(:table).to_s,
248
+ dependency_predicate_digest: nil,
249
+ dependency_metadata_snapshot: nil
250
+ }
251
+ when :active_record_attribute_any_id
252
+ _type, table, attribute = lookup_key
253
+ {
254
+ dependency_source: source,
255
+ lookup_table: table.to_s,
256
+ lookup_record_id_snapshot: nil,
257
+ lookup_attribute: attribute.to_s,
258
+ dependency_table: dependency_key.fetch(:table).to_s,
259
+ dependency_predicate_digest: nil,
260
+ dependency_metadata_snapshot: dependency_key[:scope] ? dump(scope: dependency_key[:scope]) : nil
261
+ }
262
+ when :active_record_collection_column
263
+ _type, table, attribute = lookup_key
264
+ {
265
+ dependency_source: source,
266
+ lookup_table: table.to_s,
267
+ lookup_record_id_snapshot: nil,
268
+ lookup_attribute: attribute.to_s,
269
+ dependency_table: dependency_key.fetch(:table).to_s,
270
+ dependency_predicate_digest: dependency_key.fetch(:predicate_digest).to_s,
271
+ dependency_metadata_snapshot: dump(dependency.metadata)
272
+ }
273
+ else
274
+ raise ArgumentError, "unsupported persistent lookup key: #{lookup_key.inspect}"
275
+ end
276
+ end
277
+
278
+ def subscription_with_metadata(record)
279
+ subscription = Subscription.from_h(load(record.recorder_snapshot))
280
+ metadata = subscription.metadata.merge(record.metadata.to_h)
281
+ metadata = metadata.merge(subscription_shape_key: record.subscription_shape_key) if record.subscription_shape_key
282
+
283
+ Subscription.new(
284
+ subscription.id,
285
+ subscription.subscriber_id,
286
+ subscription.recorder,
287
+ subscription.graph,
288
+ metadata
289
+ )
290
+ end
291
+
292
+ def persistent_snapshot_for(job)
293
+ subscription = job.subscription
294
+ subscription.to_persistent_h.merge(
295
+ INDEX_ENTRIES_SNAPSHOT_KEY => index_entries_snapshot(job.entries)
296
+ )
297
+ end
298
+
299
+ def index_entries_snapshot(entries)
300
+ entries.map do |entry|
301
+ {
302
+ subscription_id: entry.subscription_id,
303
+ owner_id: entry.owner_id,
304
+ dependency: entry.dependency.to_h
305
+ }
306
+ end
307
+ end
308
+
309
+ def index_entries_from_snapshot(recorder_snapshot)
310
+ snapshot = load(recorder_snapshot)
311
+ Array(snapshot.fetch(INDEX_ENTRIES_SNAPSHOT_KEY)).map do |entry_snapshot|
312
+ entry_snapshot = Dependencies.symbolize_keys(entry_snapshot)
313
+ dependency = Dependencies.from_h(entry_snapshot.fetch(:dependency))
314
+ ReverseIndex::Entry.new(
315
+ entry_snapshot.fetch(:subscription_id),
316
+ entry_snapshot.fetch(:owner_id),
317
+ dependency.cache_key,
318
+ dependency,
319
+ nil,
320
+ nil,
321
+ nil
322
+ )
323
+ end
324
+ end
325
+
326
+ def dump(value)
327
+ JsonSnapshot.dump(value)
328
+ end
329
+
330
+ def load(snapshot)
331
+ JsonSnapshot.load(snapshot)
332
+ end
333
+
334
+ def increment_count_cache(value)
335
+ @count_mutex.synchronize { @count_cache += value if @count_cache }
336
+ end
337
+
338
+ def decrement_count_cache(value)
339
+ @count_mutex.synchronize { @count_cache -= value if @count_cache }
340
+ end
341
+
342
+ def write_count_cache(value)
343
+ @count_mutex.synchronize { @count_cache = value }
344
+ end
345
+
346
+ def shape_keys_for_subscriptions(ids)
347
+ subscription_record.where(id: ids).pluck(:subscription_shape_key).compact.uniq
348
+ end
349
+
350
+ def delete_orphaned_shape_index_rows(shape_keys)
351
+ shape_keys.each do |shape_key|
352
+ next if subscription_record.where(subscription_shape_key: shape_key).exists?
353
+
354
+ shape_index_record.where(subscription_shape_key: shape_key).delete_all
355
+ end
356
+ end
357
+
358
+ def shape_index_key(job)
359
+ shape_key_for_metadata(job.subscription.metadata)
360
+ end
361
+
362
+ def shape_indexable?(job)
363
+ shape_index_key(job) && job.entries.any? && job.entries.all?(&:cohort?)
364
+ end
365
+
366
+ def index_result(direct_index_rows: 0, shape_index_rows: 0)
367
+ {
368
+ index_rows: direct_index_rows + shape_index_rows,
369
+ direct_index_rows: direct_index_rows,
370
+ shape_index_rows: shape_index_rows
371
+ }
372
+ end
373
+
374
+ def shape_key_for_metadata(metadata)
375
+ metadata_value(metadata, :subscription_shape_key)
376
+ end
377
+
378
+ def metadata_without_shape_key(metadata)
379
+ metadata.to_h.dup.tap do |attributes|
380
+ attributes.delete(:subscription_shape_key)
381
+ attributes.delete("subscription_shape_key")
382
+ end
383
+ end
384
+
385
+ def metadata_value(metadata, key)
386
+ metadata.to_h[key] || metadata.to_h[key.to_s]
387
+ end
388
+
389
+ def persist_subscription?(job)
390
+ job.operation == :persist_subscription
391
+ end
392
+
393
+ def persist_index?(job)
394
+ job.operation == :persist_index
395
+ end
396
+
397
+ def operation_counts(jobs)
398
+ jobs.each_with_object(Hash.new(0)) { |job, counts| counts[job.operation.to_s] += 1 }.to_h
399
+ end
400
+
401
+ def silence_active_record_logging
402
+ logger = ActiveRecord::Base.logger
403
+ if logger.respond_to?(:silence)
404
+ logger.silence(::Logger::WARN) { yield }
405
+ else
406
+ yield
407
+ end
408
+ end
409
+ end
410
+ end
411
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "reverse_index"
4
+
5
+ module Upkeep
6
+ module Subscriptions
7
+ class ActiveRegistry
8
+ def initialize(reverse_index: ReverseIndex.new)
9
+ @mutex = Mutex.new
10
+ @subscriptions = {}
11
+ @reverse_index = reverse_index
12
+ end
13
+
14
+ def register(subscription, entries: nil)
15
+ @mutex.synchronize do
16
+ @subscriptions[subscription.id] = subscription
17
+ if entries
18
+ @reverse_index.index_entries(entries, subscription: subscription)
19
+ else
20
+ @reverse_index.index(subscription)
21
+ end
22
+ end
23
+ end
24
+
25
+ def fetch(id)
26
+ @mutex.synchronize { @subscriptions[id] }
27
+ end
28
+
29
+ def subscriptions
30
+ @mutex.synchronize { @subscriptions.values }
31
+ end
32
+
33
+ def unregister(ids)
34
+ ids = Array(ids)
35
+ @mutex.synchronize do
36
+ ids.each do |id|
37
+ next unless @subscriptions.delete(id)
38
+
39
+ @reverse_index.delete_subscription(id)
40
+ end
41
+ end
42
+ end
43
+
44
+ def touch(id, metadata:)
45
+ @mutex.synchronize do
46
+ subscription = @subscriptions[id]
47
+ return false unless subscription
48
+
49
+ @subscriptions[id] = subscription.with(metadata: subscription.metadata.merge(metadata))
50
+ true
51
+ end
52
+ end
53
+
54
+ def entries_for(changes)
55
+ @mutex.synchronize { @reverse_index.entries_for(changes) }
56
+ end
57
+
58
+ def reset
59
+ @mutex.synchronize do
60
+ @subscriptions = {}
61
+ @reverse_index = ReverseIndex.new
62
+ end
63
+ end
64
+
65
+ def covers?(persistent_count)
66
+ count >= persistent_count
67
+ end
68
+
69
+ def count
70
+ @mutex.synchronize { @subscriptions.size }
71
+ end
72
+
73
+ def summary
74
+ @mutex.synchronize do
75
+ @reverse_index.summary.merge(subscriptions: @subscriptions.size)
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "active_support/notifications"
5
+
6
+ module Upkeep
7
+ module Subscriptions
8
+ # Shared pending -> active registry choreography for the in-memory and
9
+ # Active Record stores. Subclasses hold the @pending_registry / @active_registry
10
+ # instances and supply their store-specific tails through the documented hooks.
11
+ class BaseStore
12
+ # Opportunistic trim (Solid Cache/Solid Cable style): every TRIM_EVERY
13
+ # registrations the store deletes at most TRIM_BATCH_LIMIT subscriptions
14
+ # older than the configured subscription TTL. A deterministic counter is
15
+ # used instead of rand so the cadence is reproducible in tests.
16
+ TRIM_EVERY = 100
17
+ TRIM_BATCH_LIMIT = 500
18
+ PRUNE_NOTIFICATION = "prune.upkeep"
19
+ DEFAULT_SUBSCRIPTION_TTL = 24 * 60 * 60
20
+
21
+ def touch(id, now: Time.now)
22
+ fetch(id)
23
+ metadata = { "last_seen_at" => now.utc.iso8601 }
24
+ pending_registry.touch(id, metadata: metadata)
25
+ active_registry.touch(id, metadata: metadata)
26
+ after_touch(id, metadata: metadata, now: now)
27
+ end
28
+
29
+ def unregister(ids)
30
+ ids = Array(ids)
31
+ before_unregister(ids)
32
+ pending_registry.unregister(ids)
33
+ active_registry.unregister(ids)
34
+ after_unregister(ids)
35
+ ids.size
36
+ end
37
+
38
+ def fetch(id)
39
+ active_registry.fetch(id) || pending_registry.fetch(id) || fetch_missing(id)
40
+ end
41
+
42
+ def fetch_for_composition(id)
43
+ fetch(id)
44
+ end
45
+
46
+ def explain(id)
47
+ fetch(id).explain
48
+ end
49
+
50
+ private
51
+
52
+ # Wrap a unit of work in an optional ActiveSupport notification: when a
53
+ # listener is attached the block runs inside #instrument with the given
54
+ # payload, otherwise it runs with a nil payload and no instrumentation.
55
+ def with_optional_notification(notification, payload)
56
+ if ActiveSupport::Notifications.notifier.listening?(notification)
57
+ ActiveSupport::Notifications.instrument(notification, payload) do
58
+ yield payload
59
+ end
60
+ else
61
+ yield nil
62
+ end
63
+ end
64
+
65
+ # Runs one bounded prune batch every TRIM_EVERY registrations. Failures
66
+ # never propagate into the registration path: they are logged and the
67
+ # stale rows stay until the next trim.
68
+ def trim_opportunistically
69
+ @trim_registration_count = (@trim_registration_count || 0) + 1
70
+ return unless (@trim_registration_count % TRIM_EVERY).zero?
71
+
72
+ payload = { store: store_label, limit: TRIM_BATCH_LIMIT }
73
+ ActiveSupport::Notifications.instrument(PRUNE_NOTIFICATION, payload) do
74
+ payload[:pruned] = prune_stale!(limit: TRIM_BATCH_LIMIT)
75
+ end
76
+ rescue StandardError => error
77
+ warn_trim_failure(error)
78
+ end
79
+
80
+ def warn_trim_failure(error)
81
+ return unless defined?(::Rails) && ::Rails.respond_to?(:logger) && ::Rails.logger
82
+
83
+ ::Rails.logger.warn(
84
+ "Upkeep opportunistic subscription prune failed " \
85
+ "(#{error.class}: #{error.message}); stale subscriptions stay until the next trim"
86
+ )
87
+ end
88
+
89
+ def stale_threshold
90
+ Time.now - subscription_ttl
91
+ end
92
+
93
+ def subscription_ttl
94
+ if defined?(Upkeep::Rails) && Upkeep::Rails.respond_to?(:configuration)
95
+ Upkeep::Rails.configuration.subscription_ttl
96
+ else
97
+ DEFAULT_SUBSCRIPTION_TTL
98
+ end
99
+ end
100
+
101
+ def before_unregister(ids); end
102
+
103
+ def after_unregister(ids); end
104
+
105
+ def next_subscription_id
106
+ "subscription-#{SecureRandom.uuid}"
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Upkeep
6
+ module Subscriptions
7
+ module JsonSnapshot
8
+ VERSION = 2
9
+ VERSION_KEY = "__upkeep_snapshot_version"
10
+ VALUE_KEY = "value"
11
+ SYMBOL_VALUE_KEY = "$sym"
12
+ SYMBOL_KEY_PREFIX = "$sym:"
13
+ STRING_KEY_PREFIX = "$str:"
14
+ JSON_KEY_PREFIX = "$json:"
15
+ RESERVED_STRING_KEYS = [SYMBOL_VALUE_KEY].freeze
16
+ RESERVED_STRING_KEY_PREFIXES = [SYMBOL_KEY_PREFIX, STRING_KEY_PREFIX, JSON_KEY_PREFIX].freeze
17
+
18
+ module_function
19
+
20
+ def dump(value)
21
+ {
22
+ VERSION_KEY => VERSION,
23
+ VALUE_KEY => encode(value)
24
+ }
25
+ end
26
+
27
+ def load(snapshot)
28
+ snapshot = JSON.parse(snapshot) if snapshot.is_a?(String)
29
+ version = snapshot.fetch(VERSION_KEY)
30
+ unless version.to_i == VERSION
31
+ raise ArgumentError, "unsupported Upkeep JSON snapshot version: #{version.inspect}"
32
+ end
33
+
34
+ decode(snapshot.fetch(VALUE_KEY))
35
+ end
36
+
37
+ def encode(value)
38
+ case value
39
+ when Symbol
40
+ { SYMBOL_VALUE_KEY => value.to_s }
41
+ when Hash
42
+ value.each_with_object({}) { |(key, nested_value), encoded| encoded[encode_key(key)] = encode(nested_value) }
43
+ when Array
44
+ value.map { |nested_value| encode(nested_value) }
45
+ when nil, true, false, Numeric, String
46
+ value
47
+ else
48
+ raise TypeError, "cannot persist #{value.class.name} in an Upkeep JSON snapshot"
49
+ end
50
+ end
51
+
52
+ def decode(value)
53
+ case value
54
+ when Hash
55
+ return value.fetch(SYMBOL_VALUE_KEY).to_sym if value.size == 1 && value.key?(SYMBOL_VALUE_KEY)
56
+
57
+ value.each_with_object({}) do |(key, nested_value), decoded|
58
+ decoded[decode_key(key)] = decode(nested_value)
59
+ end
60
+ when Array
61
+ value.map { |nested_value| decode(nested_value) }
62
+ else
63
+ value
64
+ end
65
+ end
66
+
67
+ def encode_key(key)
68
+ case key
69
+ when Symbol
70
+ "#{SYMBOL_KEY_PREFIX}#{key}"
71
+ when String
72
+ reserved_string_key?(key) ? "#{STRING_KEY_PREFIX}#{key}" : key
73
+ when nil, true, false, Numeric
74
+ "#{JSON_KEY_PREFIX}#{JSON.generate(encode(key))}"
75
+ else
76
+ raise TypeError, "cannot persist #{key.class.name} as an Upkeep JSON snapshot key"
77
+ end
78
+ end
79
+
80
+ def decode_key(key)
81
+ if key.start_with?(SYMBOL_KEY_PREFIX)
82
+ key.delete_prefix(SYMBOL_KEY_PREFIX).to_sym
83
+ elsif key.start_with?(STRING_KEY_PREFIX)
84
+ key.delete_prefix(STRING_KEY_PREFIX)
85
+ elsif key.start_with?(JSON_KEY_PREFIX)
86
+ decode(JSON.parse(key.delete_prefix(JSON_KEY_PREFIX)))
87
+ else
88
+ key
89
+ end
90
+ end
91
+
92
+ def reserved_string_key?(key)
93
+ RESERVED_STRING_KEYS.include?(key) || RESERVED_STRING_KEY_PREFIXES.any? { |prefix| key.start_with?(prefix) }
94
+ end
95
+
96
+ end
97
+ end
98
+ end