upkeep-rails 0.1.9 → 0.1.12
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 +4 -4
- data/README.md +105 -195
- data/docs/drafts/turbo-streams-is-cache-invalidation-you-write-by-hand.md +240 -0
- data/docs/handoffs/_archive/2026-06-10-main.md +47 -0
- data/docs/how-it-works.md +8 -0
- data/lib/generators/upkeep/install/install_generator.rb +59 -0
- data/lib/generators/upkeep/install/templates/subscription.js +6 -5
- data/lib/generators/upkeep/install/templates/upkeep.rb +8 -7
- data/lib/upkeep/delivery/turbo_streams.rb +40 -15
- data/lib/upkeep/dependencies.rb +55 -5
- data/lib/upkeep/invalidation/planner.rb +48 -10
- data/lib/upkeep/rails/cable/channel.rb +27 -5
- data/lib/upkeep/rails/cable/subscriber_identity.rb +21 -1
- data/lib/upkeep/rails/client_subscription.rb +12 -12
- data/lib/upkeep/rails/cluster_guard.rb +57 -0
- data/lib/upkeep/rails/configuration.rb +9 -16
- data/lib/upkeep/rails/controller_runtime.rb +17 -0
- data/lib/upkeep/rails/railtie.rb +1 -10
- data/lib/upkeep/rails/testing.rb +1 -1
- data/lib/upkeep/rails.rb +58 -17
- data/lib/upkeep/runtime.rb +39 -2
- data/lib/upkeep/shared_streams.rb +17 -3
- data/lib/upkeep/subscriptions/active_record_store.rb +53 -62
- data/lib/upkeep/subscriptions/active_record_subscription_persistence.rb +6 -3
- data/lib/upkeep/subscriptions/active_registry.rb +0 -7
- data/lib/upkeep/subscriptions/base_store.rb +106 -0
- data/lib/upkeep/subscriptions/layered_reverse_index.rb +9 -13
- data/lib/upkeep/subscriptions/lookup_instrumentation.rb +32 -0
- data/lib/upkeep/subscriptions/persistent_reverse_index.rb +4 -1
- data/lib/upkeep/subscriptions/store.rb +38 -64
- data/lib/upkeep/version.rb +1 -1
- data/upkeep-rails.gemspec +0 -1
- metadata +7 -24
- data/lib/upkeep/rails/delivery_job.rb +0 -29
- data/lib/upkeep/subscriptions/async_durable_writer.rb +0 -131
|
@@ -4,6 +4,8 @@ require "securerandom"
|
|
|
4
4
|
require "time"
|
|
5
5
|
require "active_support/notifications"
|
|
6
6
|
require_relative "active_registry"
|
|
7
|
+
require_relative "base_store"
|
|
8
|
+
require_relative "lookup_instrumentation"
|
|
7
9
|
|
|
8
10
|
module Upkeep
|
|
9
11
|
module Subscriptions
|
|
@@ -123,24 +125,15 @@ module Upkeep
|
|
|
123
125
|
end
|
|
124
126
|
|
|
125
127
|
class MemoryReverseIndex
|
|
126
|
-
|
|
128
|
+
include LookupInstrumentation
|
|
129
|
+
|
|
130
|
+
LOOKUP_NOTIFICATION = LookupInstrumentation::LOOKUP_NOTIFICATION
|
|
127
131
|
|
|
128
132
|
def initialize(active_registry:, pending_registry:)
|
|
129
133
|
@active_registry = active_registry
|
|
130
134
|
@pending_registry = pending_registry
|
|
131
135
|
end
|
|
132
136
|
|
|
133
|
-
def entries_for(changes)
|
|
134
|
-
if ActiveSupport::Notifications.notifier.listening?(LOOKUP_NOTIFICATION)
|
|
135
|
-
payload = { changes: Array(changes).size, store: "memory" }
|
|
136
|
-
ActiveSupport::Notifications.instrument(LOOKUP_NOTIFICATION, payload) do
|
|
137
|
-
entries_for_with_payload(changes, payload)
|
|
138
|
-
end
|
|
139
|
-
else
|
|
140
|
-
active_registry.entries_for(changes)
|
|
141
|
-
end
|
|
142
|
-
end
|
|
143
|
-
|
|
144
137
|
def summary
|
|
145
138
|
active_registry.summary
|
|
146
139
|
end
|
|
@@ -149,6 +142,14 @@ module Upkeep
|
|
|
149
142
|
|
|
150
143
|
attr_reader :active_registry, :pending_registry
|
|
151
144
|
|
|
145
|
+
def lookup_store
|
|
146
|
+
"memory"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def entries_for_without_payload(changes)
|
|
150
|
+
active_registry.entries_for(changes)
|
|
151
|
+
end
|
|
152
|
+
|
|
152
153
|
def entries_for_with_payload(changes, payload)
|
|
153
154
|
active_entries = active_registry.entries_for(changes)
|
|
154
155
|
pending_entries = pending_registry.entries_for(changes)
|
|
@@ -173,13 +174,9 @@ module Upkeep
|
|
|
173
174
|
|
|
174
175
|
active_entries
|
|
175
176
|
end
|
|
176
|
-
|
|
177
|
-
def miss_reason(pending_entries)
|
|
178
|
-
pending_entries.any? ? "not_activated_yet" : "no_matching_subscriber"
|
|
179
|
-
end
|
|
180
177
|
end
|
|
181
178
|
|
|
182
|
-
class Store
|
|
179
|
+
class Store < BaseStore
|
|
183
180
|
PERSIST_NOTIFICATION = "persist_subscription_store.upkeep"
|
|
184
181
|
|
|
185
182
|
attr_reader :reverse_index
|
|
@@ -193,69 +190,34 @@ module Upkeep
|
|
|
193
190
|
end
|
|
194
191
|
|
|
195
192
|
def register(subscriber_id:, recorder:, metadata: {}, entries: nil)
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
ActiveSupport::Notifications.instrument(PERSIST_NOTIFICATION, payload) do
|
|
199
|
-
register_subscription(subscriber_id: subscriber_id, recorder: recorder, metadata: metadata, entries: entries, payload: payload)
|
|
200
|
-
end
|
|
201
|
-
else
|
|
202
|
-
register_subscription(subscriber_id: subscriber_id, recorder: recorder, metadata: metadata, entries: entries)
|
|
193
|
+
subscription = with_optional_notification(PERSIST_NOTIFICATION, memory_persist_payload(operation: :persist_subscription)) do |payload|
|
|
194
|
+
register_subscription(subscriber_id: subscriber_id, recorder: recorder, metadata: metadata, entries: entries, payload: payload)
|
|
203
195
|
end
|
|
196
|
+
trim_opportunistically
|
|
197
|
+
subscription
|
|
204
198
|
end
|
|
205
199
|
|
|
206
|
-
def
|
|
207
|
-
fetch(id)
|
|
208
|
-
metadata = { "last_seen_at" => now.utc.iso8601 }
|
|
209
|
-
pending_registry.touch(id, metadata: metadata)
|
|
210
|
-
active_registry.touch(id, metadata: metadata)
|
|
211
|
-
true
|
|
212
|
-
end
|
|
213
|
-
|
|
214
|
-
def prune_stale!(older_than:)
|
|
200
|
+
def prune_stale!(older_than: stale_threshold, limit: nil)
|
|
215
201
|
stale_ids = subscriptions.filter_map do |subscription|
|
|
216
202
|
id = subscription.id
|
|
217
203
|
id if last_seen_at(subscription) && last_seen_at(subscription) < older_than
|
|
218
204
|
end
|
|
205
|
+
stale_ids = stale_ids.first(limit) if limit
|
|
219
206
|
|
|
220
207
|
unregister(stale_ids)
|
|
221
208
|
stale_ids.size
|
|
222
209
|
end
|
|
223
210
|
|
|
224
|
-
def unregister(ids)
|
|
225
|
-
ids = Array(ids)
|
|
226
|
-
ids.each { |id| @pending_index_entries.delete(id) }
|
|
227
|
-
pending_registry.unregister(ids)
|
|
228
|
-
active_registry.unregister(ids)
|
|
229
|
-
ids.size
|
|
230
|
-
end
|
|
231
|
-
|
|
232
211
|
def activate(id)
|
|
233
|
-
|
|
234
|
-
payload
|
|
235
|
-
ActiveSupport::Notifications.instrument(PERSIST_NOTIFICATION, payload) do
|
|
236
|
-
activate_subscription(id, payload: payload)
|
|
237
|
-
end
|
|
238
|
-
else
|
|
239
|
-
activate_subscription(id)
|
|
212
|
+
with_optional_notification(PERSIST_NOTIFICATION, memory_persist_payload(operation: :persist_index)) do |payload|
|
|
213
|
+
activate_subscription(id, payload: payload)
|
|
240
214
|
end
|
|
241
215
|
end
|
|
242
216
|
|
|
243
|
-
def drain
|
|
244
|
-
true
|
|
245
|
-
end
|
|
246
|
-
|
|
247
217
|
def shutdown
|
|
248
218
|
true
|
|
249
219
|
end
|
|
250
220
|
|
|
251
|
-
def fetch(id)
|
|
252
|
-
active_registry.fetch(id) || pending_registry.fetch(id) || raise(NotFound, id)
|
|
253
|
-
end
|
|
254
|
-
|
|
255
|
-
def explain(id)
|
|
256
|
-
fetch(id).explain
|
|
257
|
-
end
|
|
258
|
-
|
|
259
221
|
def subscriptions
|
|
260
222
|
active_registry.subscriptions + pending_registry.subscriptions
|
|
261
223
|
end
|
|
@@ -289,6 +251,22 @@ module Upkeep
|
|
|
289
251
|
|
|
290
252
|
attr_reader :pending_registry, :active_registry
|
|
291
253
|
|
|
254
|
+
def after_touch(id, metadata:, now:)
|
|
255
|
+
true
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def store_label
|
|
259
|
+
"memory"
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def before_unregister(ids)
|
|
263
|
+
ids.each { |id| @pending_index_entries.delete(id) }
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def fetch_missing(id)
|
|
267
|
+
raise NotFound, id
|
|
268
|
+
end
|
|
269
|
+
|
|
292
270
|
def register_subscription(subscriber_id:, recorder:, metadata: {}, entries: nil, payload: nil)
|
|
293
271
|
recorder.flush_pending_dependencies if recorder.respond_to?(:flush_pending_dependencies)
|
|
294
272
|
subscription = Subscription.new(
|
|
@@ -366,10 +344,6 @@ module Upkeep
|
|
|
366
344
|
value = subscription.metadata["last_seen_at"] || subscription.metadata[:last_seen_at]
|
|
367
345
|
Time.parse(value.to_s) if value
|
|
368
346
|
end
|
|
369
|
-
|
|
370
|
-
def next_subscription_id
|
|
371
|
-
"subscription-#{SecureRandom.uuid}"
|
|
372
|
-
end
|
|
373
347
|
end
|
|
374
348
|
end
|
|
375
349
|
end
|
data/lib/upkeep/version.rb
CHANGED
data/upkeep-rails.gemspec
CHANGED
|
@@ -44,7 +44,6 @@ Gem::Specification.new do |spec|
|
|
|
44
44
|
spec.add_dependency "actionview", ">= 7.1", "< 9.0"
|
|
45
45
|
spec.add_dependency "actionpack", ">= 7.1", "< 9.0"
|
|
46
46
|
spec.add_dependency "actioncable", ">= 7.1", "< 9.0"
|
|
47
|
-
spec.add_dependency "activejob", ">= 7.1", "< 9.0"
|
|
48
47
|
spec.add_dependency "activerecord", ">= 7.1", "< 9.0"
|
|
49
48
|
spec.add_dependency "activesupport", ">= 7.1", "< 9.0"
|
|
50
49
|
spec.add_dependency "herb", ">= 0.10.1", "< 0.11"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: upkeep-rails
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.12
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Felipe dos Anjos
|
|
@@ -69,26 +69,6 @@ dependencies:
|
|
|
69
69
|
- - "<"
|
|
70
70
|
- !ruby/object:Gem::Version
|
|
71
71
|
version: '9.0'
|
|
72
|
-
- !ruby/object:Gem::Dependency
|
|
73
|
-
name: activejob
|
|
74
|
-
requirement: !ruby/object:Gem::Requirement
|
|
75
|
-
requirements:
|
|
76
|
-
- - ">="
|
|
77
|
-
- !ruby/object:Gem::Version
|
|
78
|
-
version: '7.1'
|
|
79
|
-
- - "<"
|
|
80
|
-
- !ruby/object:Gem::Version
|
|
81
|
-
version: '9.0'
|
|
82
|
-
type: :runtime
|
|
83
|
-
prerelease: false
|
|
84
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
85
|
-
requirements:
|
|
86
|
-
- - ">="
|
|
87
|
-
- !ruby/object:Gem::Version
|
|
88
|
-
version: '7.1'
|
|
89
|
-
- - "<"
|
|
90
|
-
- !ruby/object:Gem::Version
|
|
91
|
-
version: '9.0'
|
|
92
72
|
- !ruby/object:Gem::Dependency
|
|
93
73
|
name: activerecord
|
|
94
74
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -219,6 +199,8 @@ extra_rdoc_files: []
|
|
|
219
199
|
files:
|
|
220
200
|
- LICENSE.txt
|
|
221
201
|
- README.md
|
|
202
|
+
- docs/drafts/turbo-streams-is-cache-invalidation-you-write-by-hand.md
|
|
203
|
+
- docs/handoffs/_archive/2026-06-10-main.md
|
|
222
204
|
- docs/how-it-works.md
|
|
223
205
|
- lib/generators/upkeep/install/install_generator.rb
|
|
224
206
|
- lib/generators/upkeep/install/templates/create_upkeep_subscriptions.rb.erb
|
|
@@ -255,9 +237,9 @@ files:
|
|
|
255
237
|
- lib/upkeep/rails/cable/channel.rb
|
|
256
238
|
- lib/upkeep/rails/cable/subscriber_identity.rb
|
|
257
239
|
- lib/upkeep/rails/client_subscription.rb
|
|
240
|
+
- lib/upkeep/rails/cluster_guard.rb
|
|
258
241
|
- lib/upkeep/rails/configuration.rb
|
|
259
242
|
- lib/upkeep/rails/controller_runtime.rb
|
|
260
|
-
- lib/upkeep/rails/delivery_job.rb
|
|
261
243
|
- lib/upkeep/rails/install.rb
|
|
262
244
|
- lib/upkeep/rails/railtie.rb
|
|
263
245
|
- lib/upkeep/rails/replay.rb
|
|
@@ -269,9 +251,10 @@ files:
|
|
|
269
251
|
- lib/upkeep/subscriptions/active_record_store.rb
|
|
270
252
|
- lib/upkeep/subscriptions/active_record_subscription_persistence.rb
|
|
271
253
|
- lib/upkeep/subscriptions/active_registry.rb
|
|
272
|
-
- lib/upkeep/subscriptions/
|
|
254
|
+
- lib/upkeep/subscriptions/base_store.rb
|
|
273
255
|
- lib/upkeep/subscriptions/json_snapshot.rb
|
|
274
256
|
- lib/upkeep/subscriptions/layered_reverse_index.rb
|
|
257
|
+
- lib/upkeep/subscriptions/lookup_instrumentation.rb
|
|
275
258
|
- lib/upkeep/subscriptions/persistent_reverse_index.rb
|
|
276
259
|
- lib/upkeep/subscriptions/registrar.rb
|
|
277
260
|
- lib/upkeep/subscriptions/reverse_index.rb
|
|
@@ -285,7 +268,7 @@ licenses:
|
|
|
285
268
|
- MIT
|
|
286
269
|
metadata:
|
|
287
270
|
homepage_uri: https://github.com/fc-anjos/upkeep-rails
|
|
288
|
-
source_code_uri: https://github.com/fc-anjos/upkeep-rails/tree/v0.1.
|
|
271
|
+
source_code_uri: https://github.com/fc-anjos/upkeep-rails/tree/v0.1.12
|
|
289
272
|
bug_tracker_uri: https://github.com/fc-anjos/upkeep-rails/issues
|
|
290
273
|
rubygems_mfa_required: 'true'
|
|
291
274
|
rdoc_options: []
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "active_job"
|
|
4
|
-
|
|
5
|
-
module Upkeep
|
|
6
|
-
module Rails
|
|
7
|
-
class DeliveryJob < ::ActiveJob::Base
|
|
8
|
-
queue_as { Upkeep::Rails.configuration.delivery_queue }
|
|
9
|
-
|
|
10
|
-
def perform(changes)
|
|
11
|
-
Upkeep::Rails.deliver_changes_now!(normalize_changes(changes))
|
|
12
|
-
end
|
|
13
|
-
|
|
14
|
-
private
|
|
15
|
-
|
|
16
|
-
def normalize_changes(changes)
|
|
17
|
-
Array(changes).map { |change| normalize_change(change) }
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
def normalize_change(change)
|
|
21
|
-
return change unless change.respond_to?(:to_h)
|
|
22
|
-
|
|
23
|
-
change.to_h.transform_keys do |key|
|
|
24
|
-
key.respond_to?(:to_sym) ? key.to_sym : key
|
|
25
|
-
end
|
|
26
|
-
end
|
|
27
|
-
end
|
|
28
|
-
end
|
|
29
|
-
end
|
|
@@ -1,131 +0,0 @@
|
|
|
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
|
-
removed = 0
|
|
45
|
-
@queue.delete_if do |job|
|
|
46
|
-
id = job.subscription.id
|
|
47
|
-
requested_ids.key?(id).tap do |matched|
|
|
48
|
-
removed += 1 if matched
|
|
49
|
-
end
|
|
50
|
-
end
|
|
51
|
-
@pending -= removed
|
|
52
|
-
@drained.broadcast if @pending.zero?
|
|
53
|
-
@drained.wait(@mutex) while ids.any? { |id| @inflight_ids.fetch(id, 0).positive? }
|
|
54
|
-
ids
|
|
55
|
-
end
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
def drain(raise_errors: true)
|
|
59
|
-
errors = @mutex.synchronize do
|
|
60
|
-
@flush_now = true
|
|
61
|
-
@available.broadcast
|
|
62
|
-
@drained.wait(@mutex) while @pending.positive?
|
|
63
|
-
drained_errors = @errors
|
|
64
|
-
@errors = [] if raise_errors
|
|
65
|
-
drained_errors
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
raise errors.first if raise_errors && errors.any?
|
|
69
|
-
|
|
70
|
-
errors
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
def shutdown
|
|
74
|
-
drain(raise_errors: false)
|
|
75
|
-
@mutex.synchronize do
|
|
76
|
-
@closed = true
|
|
77
|
-
@available.broadcast
|
|
78
|
-
end
|
|
79
|
-
@worker.join
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
private
|
|
83
|
-
|
|
84
|
-
def work_loop
|
|
85
|
-
loop do
|
|
86
|
-
batch = next_batch
|
|
87
|
-
break unless batch
|
|
88
|
-
|
|
89
|
-
begin
|
|
90
|
-
@persist_batch.call(batch)
|
|
91
|
-
rescue StandardError => error
|
|
92
|
-
@mutex.synchronize { @errors << error }
|
|
93
|
-
ensure
|
|
94
|
-
@mutex.synchronize do
|
|
95
|
-
batch.each do |job|
|
|
96
|
-
id = job.subscription.id
|
|
97
|
-
@inflight_ids[id] -= 1
|
|
98
|
-
@inflight_ids.delete(id) unless @inflight_ids[id].positive?
|
|
99
|
-
end
|
|
100
|
-
@pending -= batch.size
|
|
101
|
-
@drained.broadcast if @pending.zero?
|
|
102
|
-
end
|
|
103
|
-
end
|
|
104
|
-
end
|
|
105
|
-
end
|
|
106
|
-
|
|
107
|
-
def next_batch
|
|
108
|
-
@mutex.synchronize do
|
|
109
|
-
@available.wait(@mutex) while @queue.empty? && !@closed
|
|
110
|
-
return nil if @queue.empty? && @closed
|
|
111
|
-
|
|
112
|
-
wait_for_batch_fill
|
|
113
|
-
@flush_now = false
|
|
114
|
-
@queue.shift(@batch_size).tap do |batch|
|
|
115
|
-
batch.each { |job| @inflight_ids[job.subscription.id] += 1 }
|
|
116
|
-
end
|
|
117
|
-
end
|
|
118
|
-
end
|
|
119
|
-
|
|
120
|
-
def wait_for_batch_fill
|
|
121
|
-
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @flush_interval
|
|
122
|
-
while @queue.size < @batch_size && !@closed && !@flush_now
|
|
123
|
-
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
124
|
-
break unless remaining.positive?
|
|
125
|
-
|
|
126
|
-
@available.wait(@mutex, remaining)
|
|
127
|
-
end
|
|
128
|
-
end
|
|
129
|
-
end
|
|
130
|
-
end
|
|
131
|
-
end
|