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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +424 -0
- data/docs/architecture/ambient-inputs-roadmap.md +306 -0
- data/docs/architecture/herb-roadmap.md +324 -0
- data/docs/architecture/identity-and-sharing.md +187 -0
- data/docs/architecture/query-dependencies.md +230 -0
- data/docs/cost-model-roadmap.md +703 -0
- data/docs/guides/getting-started.md +282 -0
- data/docs/handoff-2026-05-15.md +230 -0
- data/docs/production_roadmap.md +372 -0
- data/docs/shared-warm-scale-roadmap.md +214 -0
- data/docs/single-subscriber-cold-roadmap.md +192 -0
- data/docs/testing.md +113 -0
- data/lib/generators/upkeep/install/install_generator.rb +90 -0
- data/lib/generators/upkeep/install/templates/create_upkeep_subscriptions.rb.erb +31 -0
- data/lib/generators/upkeep/install/templates/subscription.js +107 -0
- data/lib/generators/upkeep/install/templates/upkeep.rb +6 -0
- data/lib/upkeep/active_record_query.rb +294 -0
- data/lib/upkeep/capture/request.rb +150 -0
- data/lib/upkeep/dag/subscription_shape.rb +244 -0
- data/lib/upkeep/dag.rb +370 -0
- data/lib/upkeep/delivery/action_cable_adapter.rb +43 -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 +275 -0
- data/lib/upkeep/delivery.rb +7 -0
- data/lib/upkeep/dependencies.rb +466 -0
- data/lib/upkeep/herb/developer_report.rb +116 -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 +84 -0
- data/lib/upkeep/herb/template_manifest.rb +377 -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 +341 -0
- data/lib/upkeep/invalidation.rb +7 -0
- data/lib/upkeep/rails/action_view_capture.rb +765 -0
- data/lib/upkeep/rails/cable/channel.rb +108 -0
- data/lib/upkeep/rails/cable/subscriber_identity.rb +214 -0
- data/lib/upkeep/rails/cable.rb +4 -0
- data/lib/upkeep/rails/client_subscription.rb +37 -0
- data/lib/upkeep/rails/configuration.rb +57 -0
- data/lib/upkeep/rails/controller_runtime.rb +137 -0
- data/lib/upkeep/rails/install.rb +28 -0
- data/lib/upkeep/rails/railtie.rb +36 -0
- data/lib/upkeep/rails/replay.rb +176 -0
- data/lib/upkeep/rails/testing.rb +36 -0
- data/lib/upkeep/rails.rb +276 -0
- data/lib/upkeep/replay.rb +408 -0
- data/lib/upkeep/runtime.rb +1075 -0
- data/lib/upkeep/shared_streams.rb +72 -0
- data/lib/upkeep/subscriptions/active_record_store.rb +292 -0
- data/lib/upkeep/subscriptions/active_record_subscription_persistence.rb +291 -0
- data/lib/upkeep/subscriptions/active_registry.rb +93 -0
- data/lib/upkeep/subscriptions/async_durable_writer.rb +136 -0
- data/lib/upkeep/subscriptions/json_snapshot.rb +98 -0
- data/lib/upkeep/subscriptions/layered_reverse_index.rb +122 -0
- data/lib/upkeep/subscriptions/persistent_reverse_index.rb +144 -0
- data/lib/upkeep/subscriptions/registrar.rb +36 -0
- data/lib/upkeep/subscriptions/reverse_index.rb +294 -0
- data/lib/upkeep/subscriptions/shape.rb +116 -0
- data/lib/upkeep/subscriptions/store.rb +159 -0
- data/lib/upkeep/subscriptions.rb +7 -0
- data/lib/upkeep/targeting.rb +135 -0
- data/lib/upkeep/version.rb +5 -0
- data/lib/upkeep-rails.rb +3 -0
- data/lib/upkeep.rb +14 -0
- data/upkeep-rails.gemspec +53 -0
- metadata +296 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "reverse_index"
|
|
4
|
+
require_relative "store"
|
|
5
|
+
|
|
6
|
+
module Upkeep
|
|
7
|
+
module Subscriptions
|
|
8
|
+
class ActiveRegistry
|
|
9
|
+
def initialize
|
|
10
|
+
@mutex = Mutex.new
|
|
11
|
+
@subscriptions = {}
|
|
12
|
+
@reverse_index = ReverseIndex.new
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def register(subscription, entries: nil)
|
|
16
|
+
@mutex.synchronize do
|
|
17
|
+
@subscriptions[subscription.id] = subscription
|
|
18
|
+
if entries
|
|
19
|
+
@reverse_index.index_entries(entries, subscription: subscription)
|
|
20
|
+
else
|
|
21
|
+
@reverse_index.index(subscription)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def fetch(id)
|
|
27
|
+
@mutex.synchronize { @subscriptions[id] }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def subscriptions
|
|
31
|
+
@mutex.synchronize { @subscriptions.values }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def unregister(ids)
|
|
35
|
+
ids = Array(ids)
|
|
36
|
+
@mutex.synchronize do
|
|
37
|
+
ids.each do |id|
|
|
38
|
+
next unless @subscriptions.delete(id)
|
|
39
|
+
|
|
40
|
+
@reverse_index.delete_subscription(id)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def touch(id, metadata:)
|
|
46
|
+
@mutex.synchronize do
|
|
47
|
+
subscription = @subscriptions[id]
|
|
48
|
+
return unless subscription
|
|
49
|
+
|
|
50
|
+
@subscriptions[id] = Subscription.new(
|
|
51
|
+
subscription.id,
|
|
52
|
+
subscription.subscriber_id,
|
|
53
|
+
subscription.recorder,
|
|
54
|
+
subscription.graph,
|
|
55
|
+
subscription.metadata.merge(metadata)
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def entries_for(changes)
|
|
61
|
+
@mutex.synchronize { @reverse_index.entries_for(changes) }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def reset
|
|
65
|
+
@mutex.synchronize do
|
|
66
|
+
@subscriptions = {}
|
|
67
|
+
@reverse_index = ReverseIndex.new
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def covers?(persistent_count)
|
|
72
|
+
count >= persistent_count
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def count
|
|
76
|
+
@mutex.synchronize { @subscriptions.size }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def summary
|
|
80
|
+
@mutex.synchronize do
|
|
81
|
+
@reverse_index.summary.merge(subscriptions: @subscriptions.size)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def rebuild_reverse_index!
|
|
88
|
+
@reverse_index = ReverseIndex.new
|
|
89
|
+
@subscriptions.each_value { |subscription| @reverse_index.index(subscription) }
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Upkeep
|
|
4
|
+
module Subscriptions
|
|
5
|
+
class AsyncDurableWriter
|
|
6
|
+
DEFAULT_BATCH_SIZE = 100
|
|
7
|
+
DEFAULT_FLUSH_INTERVAL = 1.0
|
|
8
|
+
Job = Data.define(:subscription, :entries, :operation)
|
|
9
|
+
|
|
10
|
+
def initialize(batch_size: DEFAULT_BATCH_SIZE, flush_interval: DEFAULT_FLUSH_INTERVAL, &persist_batch)
|
|
11
|
+
@batch_size = batch_size
|
|
12
|
+
@flush_interval = flush_interval
|
|
13
|
+
@persist_batch = persist_batch
|
|
14
|
+
@mutex = Mutex.new
|
|
15
|
+
@available = ConditionVariable.new
|
|
16
|
+
@drained = ConditionVariable.new
|
|
17
|
+
@queue = []
|
|
18
|
+
@pending = 0
|
|
19
|
+
@inflight_ids = Hash.new(0)
|
|
20
|
+
@closed = false
|
|
21
|
+
@flush_now = false
|
|
22
|
+
@errors = []
|
|
23
|
+
@worker = Thread.new { work_loop }
|
|
24
|
+
@worker.name = "upkeep-durable-writer" if @worker.respond_to?(:name=)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def enqueue(subscription, entries:, operation:)
|
|
28
|
+
@mutex.synchronize do
|
|
29
|
+
raise IOError, "Upkeep durable writer is closed" if @closed
|
|
30
|
+
|
|
31
|
+
@queue << Job.new(subscription, entries, operation)
|
|
32
|
+
@pending += 1
|
|
33
|
+
@available.signal
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def cancel(ids)
|
|
38
|
+
ids = Array(ids)
|
|
39
|
+
return [] if ids.empty?
|
|
40
|
+
|
|
41
|
+
requested_ids = ids.to_h { |id| [id, true] }
|
|
42
|
+
|
|
43
|
+
@mutex.synchronize do
|
|
44
|
+
queued_ids = {}
|
|
45
|
+
removed = 0
|
|
46
|
+
@queue.delete_if do |job|
|
|
47
|
+
id = job.subscription.id
|
|
48
|
+
requested_ids.key?(id).tap do |matched|
|
|
49
|
+
if matched
|
|
50
|
+
queued_ids[id] = true
|
|
51
|
+
removed += 1
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
@pending -= removed
|
|
56
|
+
@drained.broadcast if @pending.zero?
|
|
57
|
+
persisted_ids = ids.reject { |id| queued_ids[id] }
|
|
58
|
+
@drained.wait(@mutex) while persisted_ids.any? { |id| @inflight_ids.fetch(id, 0).positive? }
|
|
59
|
+
persisted_ids
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def drain(raise_errors: true)
|
|
64
|
+
errors = @mutex.synchronize do
|
|
65
|
+
@flush_now = true
|
|
66
|
+
@available.broadcast
|
|
67
|
+
@drained.wait(@mutex) while @pending.positive?
|
|
68
|
+
drained_errors = @errors
|
|
69
|
+
@errors = [] if raise_errors
|
|
70
|
+
drained_errors
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
raise errors.first if raise_errors && errors.any?
|
|
74
|
+
|
|
75
|
+
errors
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def shutdown
|
|
79
|
+
drain(raise_errors: false)
|
|
80
|
+
@mutex.synchronize do
|
|
81
|
+
@closed = true
|
|
82
|
+
@available.broadcast
|
|
83
|
+
end
|
|
84
|
+
@worker.join
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def work_loop
|
|
90
|
+
loop do
|
|
91
|
+
batch = next_batch
|
|
92
|
+
break unless batch
|
|
93
|
+
|
|
94
|
+
begin
|
|
95
|
+
@persist_batch.call(batch)
|
|
96
|
+
rescue StandardError => error
|
|
97
|
+
@mutex.synchronize { @errors << error }
|
|
98
|
+
ensure
|
|
99
|
+
@mutex.synchronize do
|
|
100
|
+
batch.each do |job|
|
|
101
|
+
id = job.subscription.id
|
|
102
|
+
@inflight_ids[id] -= 1
|
|
103
|
+
@inflight_ids.delete(id) unless @inflight_ids[id].positive?
|
|
104
|
+
end
|
|
105
|
+
@pending -= batch.size
|
|
106
|
+
@drained.broadcast if @pending.zero?
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def next_batch
|
|
113
|
+
@mutex.synchronize do
|
|
114
|
+
@available.wait(@mutex) while @queue.empty? && !@closed
|
|
115
|
+
return nil if @queue.empty? && @closed
|
|
116
|
+
|
|
117
|
+
wait_for_batch_fill
|
|
118
|
+
@flush_now = false
|
|
119
|
+
@queue.shift(@batch_size).tap do |batch|
|
|
120
|
+
batch.each { |job| @inflight_ids[job.subscription.id] += 1 }
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def wait_for_batch_fill
|
|
126
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @flush_interval
|
|
127
|
+
while @queue.size < @batch_size && !@closed && !@flush_now
|
|
128
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
129
|
+
break unless remaining.positive?
|
|
130
|
+
|
|
131
|
+
@available.wait(@mutex, remaining)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
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
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/notifications"
|
|
4
|
+
|
|
5
|
+
module Upkeep
|
|
6
|
+
module Subscriptions
|
|
7
|
+
class LayeredReverseIndex
|
|
8
|
+
LOOKUP_NOTIFICATION = "lookup_subscription_index.upkeep"
|
|
9
|
+
|
|
10
|
+
def initialize(active_index:, persistent_index:, persistent_count:, store:, pending_index: nil)
|
|
11
|
+
@active_index = active_index
|
|
12
|
+
@persistent_index = persistent_index
|
|
13
|
+
@persistent_count = persistent_count
|
|
14
|
+
@store = store
|
|
15
|
+
@pending_index = pending_index
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def entries_for(changes)
|
|
19
|
+
if ActiveSupport::Notifications.notifier.listening?(LOOKUP_NOTIFICATION)
|
|
20
|
+
payload = { changes: Array(changes).size, store: store }
|
|
21
|
+
ActiveSupport::Notifications.instrument(LOOKUP_NOTIFICATION, payload) do
|
|
22
|
+
entries_for_with_payload(changes, payload)
|
|
23
|
+
end
|
|
24
|
+
else
|
|
25
|
+
entries_for_without_payload(changes)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def entries_for_without_payload(changes)
|
|
30
|
+
active_entries = active_index.entries_for(changes)
|
|
31
|
+
return persistent_index.entries_for(changes) if active_index.count.zero?
|
|
32
|
+
return active_entries if active_index.covers?(persistent_subscription_count)
|
|
33
|
+
|
|
34
|
+
merge_entries(active_entries, persistent_index.entries_for(changes))
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def entries_for_with_payload(changes, payload)
|
|
38
|
+
active_entries = active_index.entries_for(changes)
|
|
39
|
+
active_count = active_index.count
|
|
40
|
+
pending_entries = pending_entries_for(changes)
|
|
41
|
+
pending_count = pending_count_for_payload
|
|
42
|
+
payload[:active_entries] = active_entries.size
|
|
43
|
+
payload[:active_subscriptions] = active_count
|
|
44
|
+
payload[:pending_entries] = pending_entries.size
|
|
45
|
+
payload[:pending_subscriptions] = pending_count
|
|
46
|
+
|
|
47
|
+
if active_count.zero?
|
|
48
|
+
persistent_entries = persistent_index.entries_for(changes)
|
|
49
|
+
payload[:mode] = persistent_entries.empty? && pending_entries.any? ? "pending_activation" : "persistent"
|
|
50
|
+
payload[:persistent_entries] = persistent_entries.size
|
|
51
|
+
apply_miss_reason(payload, active_entries: active_entries, persistent_entries: persistent_entries, pending_entries: pending_entries)
|
|
52
|
+
return persistent_entries
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
if active_index.covers?(persistent_subscription_count)
|
|
56
|
+
payload[:mode] = "active"
|
|
57
|
+
payload[:persistent_entries] = 0
|
|
58
|
+
apply_miss_reason(payload, active_entries: active_entries, persistent_entries: [], pending_entries: pending_entries)
|
|
59
|
+
return active_entries
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
persistent_entries = persistent_index.entries_for(changes)
|
|
63
|
+
payload[:mode] = "active_and_persistent"
|
|
64
|
+
payload[:persistent_entries] = persistent_entries.size
|
|
65
|
+
apply_miss_reason(payload, active_entries: active_entries, persistent_entries: persistent_entries, pending_entries: pending_entries)
|
|
66
|
+
merge_entries(active_entries, persistent_entries)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def summary
|
|
70
|
+
persistent = persistent_index.summary
|
|
71
|
+
active = active_index.summary
|
|
72
|
+
pending = pending_index&.summary || { lookup_keys: 0, entries: 0, subscriptions: 0 }
|
|
73
|
+
mode = active_index.covers?(persistent_subscription_count) ? :active : :active_and_persistent
|
|
74
|
+
totals = if mode == :active
|
|
75
|
+
active
|
|
76
|
+
else
|
|
77
|
+
{
|
|
78
|
+
lookup_keys: active.fetch(:lookup_keys) + persistent.fetch(:lookup_keys),
|
|
79
|
+
entries: active.fetch(:entries) + persistent.fetch(:entries)
|
|
80
|
+
}
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
{
|
|
84
|
+
lookup_keys: totals.fetch(:lookup_keys),
|
|
85
|
+
entries: totals.fetch(:entries),
|
|
86
|
+
mode: mode,
|
|
87
|
+
active: active,
|
|
88
|
+
pending: pending,
|
|
89
|
+
persistent: persistent
|
|
90
|
+
}
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
attr_reader :active_index, :persistent_index, :persistent_count, :store, :pending_index
|
|
96
|
+
|
|
97
|
+
def persistent_subscription_count
|
|
98
|
+
persistent_count.call
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def merge_entries(active_entries, persistent_entries)
|
|
102
|
+
(active_entries + persistent_entries).uniq do |entry|
|
|
103
|
+
[entry.subscription_id, entry.owner_id, entry.dependency_cache_key]
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def pending_entries_for(changes)
|
|
108
|
+
pending_index ? pending_index.entries_for(changes) : []
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def pending_count_for_payload
|
|
112
|
+
pending_index&.count || 0
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def apply_miss_reason(payload, active_entries:, persistent_entries:, pending_entries:)
|
|
116
|
+
return if active_entries.any? || persistent_entries.any?
|
|
117
|
+
|
|
118
|
+
payload[:miss_reason] = pending_entries.any? ? "not_activated_yet" : "no_matching_subscriber"
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
require_relative "../dependencies"
|
|
6
|
+
require_relative "json_snapshot"
|
|
7
|
+
require_relative "reverse_index"
|
|
8
|
+
|
|
9
|
+
module Upkeep
|
|
10
|
+
module Subscriptions
|
|
11
|
+
class PersistentReverseIndex
|
|
12
|
+
LOOKUP_COLUMNS = [
|
|
13
|
+
:subscription_id,
|
|
14
|
+
:lookup_key_digest,
|
|
15
|
+
:dependency_source,
|
|
16
|
+
:lookup_table,
|
|
17
|
+
:lookup_record_id_snapshot,
|
|
18
|
+
:lookup_attribute,
|
|
19
|
+
:dependency_table,
|
|
20
|
+
:dependency_predicate_digest,
|
|
21
|
+
:dependency_metadata_snapshot,
|
|
22
|
+
:owner_ids_snapshot
|
|
23
|
+
].freeze
|
|
24
|
+
|
|
25
|
+
def initialize(reverse_index:, index_record:)
|
|
26
|
+
@reverse_index = reverse_index
|
|
27
|
+
@index_record = index_record
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def entries_for(changes)
|
|
31
|
+
persistent_entries_for(changes)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def summary
|
|
35
|
+
{
|
|
36
|
+
lookup_keys: index_record.distinct.count(:lookup_key_digest),
|
|
37
|
+
entries: index_record.count
|
|
38
|
+
}
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.digest(value)
|
|
42
|
+
Digest::SHA256.hexdigest(JSON.generate(canonical_lookup_value(value)))
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.canonical_lookup_value(value)
|
|
46
|
+
case value
|
|
47
|
+
when Array
|
|
48
|
+
value.map { |item| canonical_lookup_value(item) }
|
|
49
|
+
when Hash
|
|
50
|
+
value.keys.sort_by(&:to_s).map do |key|
|
|
51
|
+
[canonical_lookup_value(key), canonical_lookup_value(value.fetch(key))]
|
|
52
|
+
end
|
|
53
|
+
when Symbol
|
|
54
|
+
["symbol", value.to_s]
|
|
55
|
+
when String
|
|
56
|
+
["string", value.encode(Encoding::UTF_8)]
|
|
57
|
+
else
|
|
58
|
+
value
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
attr_reader :reverse_index, :index_record
|
|
65
|
+
|
|
66
|
+
def persistent_entries_for(changes)
|
|
67
|
+
lookup_keys = Array(changes).flat_map { |change| reverse_index.lookup_keys_for_change(change) }.uniq
|
|
68
|
+
lookup_keys_by_digest = Hash.new { |hash, digest| hash[digest] = [] }
|
|
69
|
+
lookup_keys.each do |lookup_key|
|
|
70
|
+
lookup_keys_by_digest[self.class.digest(lookup_key)] << lookup_key
|
|
71
|
+
end
|
|
72
|
+
lookup_key_digests = lookup_keys_by_digest.keys
|
|
73
|
+
|
|
74
|
+
index_record
|
|
75
|
+
.where(lookup_key_digest: lookup_key_digests)
|
|
76
|
+
.pluck(*LOOKUP_COLUMNS)
|
|
77
|
+
.flat_map { |row| entries_for_row(row, lookup_keys_by_digest) }
|
|
78
|
+
.uniq { |entry| [entry.subscription_id, entry.owner_id, entry.dependency_cache_key] }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def entries_for_row(row, lookup_keys_by_digest)
|
|
82
|
+
attributes = LOOKUP_COLUMNS.zip(row).to_h
|
|
83
|
+
lookup_keys = lookup_keys_by_digest.fetch(attributes.fetch(:lookup_key_digest)) { return [] }
|
|
84
|
+
return [] unless lookup_keys.any? { |lookup_key| lookup_key_matches_row?(lookup_key, attributes) }
|
|
85
|
+
|
|
86
|
+
dependency = dependency_for_row(attributes)
|
|
87
|
+
dependency_cache_key = dependency.cache_key
|
|
88
|
+
JsonSnapshot.load(attributes.fetch(:owner_ids_snapshot)).map do |owner_id|
|
|
89
|
+
ReverseIndex::Entry.new(
|
|
90
|
+
attributes.fetch(:subscription_id),
|
|
91
|
+
owner_id,
|
|
92
|
+
dependency_cache_key,
|
|
93
|
+
dependency,
|
|
94
|
+
nil,
|
|
95
|
+
nil
|
|
96
|
+
)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def lookup_key_matches_row?(lookup_key, attributes)
|
|
101
|
+
case lookup_key.fetch(0)
|
|
102
|
+
when :active_record_attribute
|
|
103
|
+
lookup_key.fetch(1).to_s == attributes.fetch(:lookup_table).to_s &&
|
|
104
|
+
lookup_key.fetch(2) == JsonSnapshot.load(attributes.fetch(:lookup_record_id_snapshot)) &&
|
|
105
|
+
lookup_key.fetch(3).to_s == attributes.fetch(:lookup_attribute).to_s
|
|
106
|
+
when :active_record_attribute_any_id
|
|
107
|
+
lookup_key.fetch(1).to_s == attributes.fetch(:lookup_table).to_s &&
|
|
108
|
+
attributes.fetch(:lookup_record_id_snapshot).nil? &&
|
|
109
|
+
lookup_key.fetch(2).to_s == attributes.fetch(:lookup_attribute).to_s
|
|
110
|
+
when :active_record_collection_column
|
|
111
|
+
lookup_key.fetch(1).to_s == attributes.fetch(:lookup_table).to_s &&
|
|
112
|
+
attributes.fetch(:lookup_record_id_snapshot).nil? &&
|
|
113
|
+
lookup_key.fetch(2).to_s == attributes.fetch(:lookup_attribute).to_s
|
|
114
|
+
else
|
|
115
|
+
false
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def dependency_for_row(attributes)
|
|
120
|
+
source = attributes.fetch(:dependency_source).to_sym
|
|
121
|
+
case source
|
|
122
|
+
when :active_record_attribute
|
|
123
|
+
Dependencies::ActiveRecordAttribute.new(
|
|
124
|
+
table: attributes.fetch(:dependency_table),
|
|
125
|
+
id: attributes[:lookup_record_id_snapshot] && JsonSnapshot.load(attributes.fetch(:lookup_record_id_snapshot)),
|
|
126
|
+
attribute: attributes.fetch(:lookup_attribute)
|
|
127
|
+
)
|
|
128
|
+
when :active_record_collection, :active_record_query
|
|
129
|
+
metadata = JsonSnapshot.load(attributes.fetch(:dependency_metadata_snapshot))
|
|
130
|
+
dependency_class = source == :active_record_query ? Dependencies::ActiveRecordQuery : Dependencies::ActiveRecordCollection
|
|
131
|
+
dependency_class.new(
|
|
132
|
+
primary_table: attributes.fetch(:dependency_table),
|
|
133
|
+
table_columns: metadata.fetch(:table_columns),
|
|
134
|
+
coverage: metadata.fetch(:coverage),
|
|
135
|
+
sql: metadata.fetch(:sql),
|
|
136
|
+
predicates: metadata.fetch(:predicates)
|
|
137
|
+
)
|
|
138
|
+
else
|
|
139
|
+
raise ArgumentError, "unsupported persistent dependency source: #{source.inspect}"
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "shape"
|
|
4
|
+
|
|
5
|
+
module Upkeep
|
|
6
|
+
module Subscriptions
|
|
7
|
+
class Registrar
|
|
8
|
+
Registration = Data.define(:identity, :decision, :subscription, :shape)
|
|
9
|
+
|
|
10
|
+
def initialize(store:, shape_cache: ShapeCache.new)
|
|
11
|
+
@store = store
|
|
12
|
+
@shape_cache = shape_cache
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def register(identity:, decision:, recorder:, metadata: {}, signature: nil)
|
|
16
|
+
shape = shape_cache.resolve(recorder: recorder, decision: decision, signature: signature)
|
|
17
|
+
subscription = store.register(
|
|
18
|
+
subscriber_id: identity.subscriber_id,
|
|
19
|
+
recorder: recorder,
|
|
20
|
+
metadata: metadata.merge(
|
|
21
|
+
shared_stream_names: shape.shared_stream_names,
|
|
22
|
+
subscription_shape_key: shape.key,
|
|
23
|
+
subscription_shape_cache: shape.cache_state
|
|
24
|
+
).compact,
|
|
25
|
+
entries: shape.entries
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
Registration.new(identity, decision, subscription, shape)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
attr_reader :store, :shape_cache
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|