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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +244 -0
- data/docs/drafts/turbo-streams-is-cache-invalidation-you-write-by-hand.md +240 -0
- data/docs/how-it-works.md +329 -0
- data/docs/plans/sqlglot-active-record-migration.md +211 -0
- data/docs/plans/turbo-frame-subscription-composition.md +90 -0
- data/docs/spikes/SQLGLOT_VS_AREL_FINDINGS.md +136 -0
- data/docs/spikes/query-source-analysis-comparison/FINDINGS.md +169 -0
- data/docs/spikes/sqlglot-first-active-record/README.md +26 -0
- data/docs/spikes/sqlglot-query-analysis/FINDINGS.md +110 -0
- data/docs/spikes/sqlglot-query-analysis/PULSE_1802_FINDINGS.md +95 -0
- data/docs/spikes/sqlglot-semantic-bindings/README.md +29 -0
- data/lib/generators/upkeep/install/install_generator.rb +192 -0
- data/lib/generators/upkeep/install/templates/create_upkeep_subscriptions.rb.erb +49 -0
- data/lib/generators/upkeep/install/templates/subscription.js +288 -0
- data/lib/generators/upkeep/install/templates/upkeep.rb +65 -0
- data/lib/upkeep/active_record_query.rb +248 -0
- data/lib/upkeep/capture/request.rb +150 -0
- data/lib/upkeep/dag/subscription_shape.rb +244 -0
- data/lib/upkeep/dag.rb +454 -0
- data/lib/upkeep/delivery/action_cable_adapter.rb +48 -0
- data/lib/upkeep/delivery/async_dispatcher.rb +102 -0
- data/lib/upkeep/delivery/broadcast_transport.rb +89 -0
- data/lib/upkeep/delivery/transport.rb +194 -0
- data/lib/upkeep/delivery/turbo_streams.rb +339 -0
- data/lib/upkeep/delivery.rb +7 -0
- data/lib/upkeep/dependencies.rb +600 -0
- data/lib/upkeep/herb/developer_report.rb +135 -0
- data/lib/upkeep/herb/manifest_cache.rb +83 -0
- data/lib/upkeep/herb/manifest_diff.rb +183 -0
- data/lib/upkeep/herb/source_instrumenter.rb +149 -0
- data/lib/upkeep/herb/template_manifest.rb +548 -0
- data/lib/upkeep/invalidation/collection_append.rb +84 -0
- data/lib/upkeep/invalidation/collection_member_replace.rb +78 -0
- data/lib/upkeep/invalidation/collection_prepend.rb +84 -0
- data/lib/upkeep/invalidation/collection_remove.rb +57 -0
- data/lib/upkeep/invalidation/planner.rb +411 -0
- data/lib/upkeep/invalidation.rb +7 -0
- data/lib/upkeep/rails/action_view_capture.rb +1007 -0
- data/lib/upkeep/rails/activation_token.rb +55 -0
- data/lib/upkeep/rails/cable/channel.rb +165 -0
- data/lib/upkeep/rails/cable/subscriber_identity.rb +361 -0
- data/lib/upkeep/rails/cable.rb +4 -0
- data/lib/upkeep/rails/client_subscription.rb +65 -0
- data/lib/upkeep/rails/cluster_guard.rb +57 -0
- data/lib/upkeep/rails/configuration.rb +252 -0
- data/lib/upkeep/rails/controller_runtime.rb +187 -0
- data/lib/upkeep/rails/install.rb +28 -0
- data/lib/upkeep/rails/job_runtime.rb +43 -0
- data/lib/upkeep/rails/railtie.rb +44 -0
- data/lib/upkeep/rails/replay.rb +244 -0
- data/lib/upkeep/rails/testing.rb +259 -0
- data/lib/upkeep/rails.rb +466 -0
- data/lib/upkeep/replay.rb +462 -0
- data/lib/upkeep/runtime.rb +1276 -0
- data/lib/upkeep/shared_streams.rb +86 -0
- data/lib/upkeep/sql_dependency_analysis.rb +553 -0
- data/lib/upkeep/sqlglot/libsqlglot_rust.so +0 -0
- data/lib/upkeep/sqlglot/native.rb +121 -0
- data/lib/upkeep/sqlglot/native_library.rb +23 -0
- data/lib/upkeep/sqlglot.rb +367 -0
- data/lib/upkeep/subscriptions/active_record_store.rb +398 -0
- data/lib/upkeep/subscriptions/active_record_subscription_persistence.rb +411 -0
- data/lib/upkeep/subscriptions/active_registry.rb +80 -0
- data/lib/upkeep/subscriptions/base_store.rb +110 -0
- data/lib/upkeep/subscriptions/json_snapshot.rb +98 -0
- data/lib/upkeep/subscriptions/layered_reverse_index.rb +125 -0
- data/lib/upkeep/subscriptions/lookup_instrumentation.rb +32 -0
- data/lib/upkeep/subscriptions/persistent_reverse_index.rb +228 -0
- data/lib/upkeep/subscriptions/registrar.rb +36 -0
- data/lib/upkeep/subscriptions/reverse_index.rb +313 -0
- data/lib/upkeep/subscriptions/shape.rb +117 -0
- data/lib/upkeep/subscriptions/store.rb +349 -0
- data/lib/upkeep/subscriptions.rb +7 -0
- data/lib/upkeep/targeting.rb +146 -0
- data/lib/upkeep/version.rb +5 -0
- data/lib/upkeep-rails.rb +3 -0
- data/lib/upkeep.rb +15 -0
- data/upkeep-rails.gemspec +66 -0
- 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
|