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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +105 -195
  3. data/docs/drafts/turbo-streams-is-cache-invalidation-you-write-by-hand.md +240 -0
  4. data/docs/handoffs/_archive/2026-06-10-main.md +47 -0
  5. data/docs/how-it-works.md +8 -0
  6. data/lib/generators/upkeep/install/install_generator.rb +59 -0
  7. data/lib/generators/upkeep/install/templates/subscription.js +6 -5
  8. data/lib/generators/upkeep/install/templates/upkeep.rb +8 -7
  9. data/lib/upkeep/delivery/turbo_streams.rb +40 -15
  10. data/lib/upkeep/dependencies.rb +55 -5
  11. data/lib/upkeep/invalidation/planner.rb +48 -10
  12. data/lib/upkeep/rails/cable/channel.rb +27 -5
  13. data/lib/upkeep/rails/cable/subscriber_identity.rb +21 -1
  14. data/lib/upkeep/rails/client_subscription.rb +12 -12
  15. data/lib/upkeep/rails/cluster_guard.rb +57 -0
  16. data/lib/upkeep/rails/configuration.rb +9 -16
  17. data/lib/upkeep/rails/controller_runtime.rb +17 -0
  18. data/lib/upkeep/rails/railtie.rb +1 -10
  19. data/lib/upkeep/rails/testing.rb +1 -1
  20. data/lib/upkeep/rails.rb +58 -17
  21. data/lib/upkeep/runtime.rb +39 -2
  22. data/lib/upkeep/shared_streams.rb +17 -3
  23. data/lib/upkeep/subscriptions/active_record_store.rb +53 -62
  24. data/lib/upkeep/subscriptions/active_record_subscription_persistence.rb +6 -3
  25. data/lib/upkeep/subscriptions/active_registry.rb +0 -7
  26. data/lib/upkeep/subscriptions/base_store.rb +106 -0
  27. data/lib/upkeep/subscriptions/layered_reverse_index.rb +9 -13
  28. data/lib/upkeep/subscriptions/lookup_instrumentation.rb +32 -0
  29. data/lib/upkeep/subscriptions/persistent_reverse_index.rb +4 -1
  30. data/lib/upkeep/subscriptions/store.rb +38 -64
  31. data/lib/upkeep/version.rb +1 -1
  32. data/upkeep-rails.gemspec +0 -1
  33. metadata +7 -24
  34. data/lib/upkeep/rails/delivery_job.rb +0 -29
  35. 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
- LOOKUP_NOTIFICATION = "lookup_subscription_index.upkeep"
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
- if ActiveSupport::Notifications.notifier.listening?(PERSIST_NOTIFICATION)
197
- payload = memory_persist_payload(operation: :persist_subscription)
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 touch(id, now: Time.now)
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
- if ActiveSupport::Notifications.notifier.listening?(PERSIST_NOTIFICATION)
234
- payload = memory_persist_payload(operation: :persist_index)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Upkeep
4
- VERSION = "0.1.9"
4
+ VERSION = "0.1.12"
5
5
  end
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.9
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/async_durable_writer.rb
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.9
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