upkeep-rails 0.1.6

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.

Files changed (77) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +614 -0
  4. data/docs/architecture/ambient-inputs-roadmap.md +308 -0
  5. data/docs/architecture/herb-roadmap.md +324 -0
  6. data/docs/architecture/identity-and-sharing.md +306 -0
  7. data/docs/architecture/query-dependencies.md +230 -0
  8. data/docs/architecture/subscription-store-contract.md +66 -0
  9. data/docs/cost-model-roadmap.md +704 -0
  10. data/docs/guides/getting-started.md +462 -0
  11. data/docs/handoff-2026-05-15.md +230 -0
  12. data/docs/production_roadmap.md +372 -0
  13. data/docs/shared-warm-scale-roadmap.md +214 -0
  14. data/docs/single-subscriber-cold-roadmap.md +192 -0
  15. data/docs/stress-test-findings.md +310 -0
  16. data/docs/testing.md +143 -0
  17. data/lib/generators/upkeep/install/install_generator.rb +127 -0
  18. data/lib/generators/upkeep/install/templates/create_upkeep_subscriptions.rb.erb +49 -0
  19. data/lib/generators/upkeep/install/templates/subscription.js +99 -0
  20. data/lib/generators/upkeep/install/templates/upkeep.rb +63 -0
  21. data/lib/upkeep/active_record_query.rb +294 -0
  22. data/lib/upkeep/capture/request.rb +150 -0
  23. data/lib/upkeep/dag/subscription_shape.rb +244 -0
  24. data/lib/upkeep/dag.rb +370 -0
  25. data/lib/upkeep/delivery/action_cable_adapter.rb +43 -0
  26. data/lib/upkeep/delivery/async_dispatcher.rb +102 -0
  27. data/lib/upkeep/delivery/broadcast_transport.rb +89 -0
  28. data/lib/upkeep/delivery/transport.rb +194 -0
  29. data/lib/upkeep/delivery/turbo_streams.rb +302 -0
  30. data/lib/upkeep/delivery.rb +7 -0
  31. data/lib/upkeep/dependencies.rb +518 -0
  32. data/lib/upkeep/herb/developer_report.rb +135 -0
  33. data/lib/upkeep/herb/manifest_cache.rb +83 -0
  34. data/lib/upkeep/herb/manifest_diff.rb +183 -0
  35. data/lib/upkeep/herb/source_instrumenter.rb +149 -0
  36. data/lib/upkeep/herb/template_manifest.rb +514 -0
  37. data/lib/upkeep/invalidation/collection_append.rb +84 -0
  38. data/lib/upkeep/invalidation/collection_member_replace.rb +78 -0
  39. data/lib/upkeep/invalidation/collection_prepend.rb +84 -0
  40. data/lib/upkeep/invalidation/collection_remove.rb +57 -0
  41. data/lib/upkeep/invalidation/planner.rb +360 -0
  42. data/lib/upkeep/invalidation.rb +7 -0
  43. data/lib/upkeep/rails/action_view_capture.rb +821 -0
  44. data/lib/upkeep/rails/activation_token.rb +55 -0
  45. data/lib/upkeep/rails/cable/channel.rb +143 -0
  46. data/lib/upkeep/rails/cable/subscriber_identity.rb +341 -0
  47. data/lib/upkeep/rails/cable.rb +4 -0
  48. data/lib/upkeep/rails/client_subscription.rb +45 -0
  49. data/lib/upkeep/rails/configuration.rb +245 -0
  50. data/lib/upkeep/rails/controller_runtime.rb +137 -0
  51. data/lib/upkeep/rails/delivery_job.rb +29 -0
  52. data/lib/upkeep/rails/install.rb +28 -0
  53. data/lib/upkeep/rails/railtie.rb +50 -0
  54. data/lib/upkeep/rails/replay.rb +176 -0
  55. data/lib/upkeep/rails/testing.rb +97 -0
  56. data/lib/upkeep/rails.rb +349 -0
  57. data/lib/upkeep/replay.rb +408 -0
  58. data/lib/upkeep/runtime.rb +1100 -0
  59. data/lib/upkeep/shared_streams.rb +72 -0
  60. data/lib/upkeep/subscriptions/active_record_store.rb +383 -0
  61. data/lib/upkeep/subscriptions/active_record_subscription_persistence.rb +407 -0
  62. data/lib/upkeep/subscriptions/active_registry.rb +87 -0
  63. data/lib/upkeep/subscriptions/async_durable_writer.rb +131 -0
  64. data/lib/upkeep/subscriptions/json_snapshot.rb +98 -0
  65. data/lib/upkeep/subscriptions/layered_reverse_index.rb +129 -0
  66. data/lib/upkeep/subscriptions/persistent_reverse_index.rb +223 -0
  67. data/lib/upkeep/subscriptions/registrar.rb +36 -0
  68. data/lib/upkeep/subscriptions/reverse_index.rb +298 -0
  69. data/lib/upkeep/subscriptions/shape.rb +116 -0
  70. data/lib/upkeep/subscriptions/store.rb +171 -0
  71. data/lib/upkeep/subscriptions.rb +7 -0
  72. data/lib/upkeep/targeting.rb +135 -0
  73. data/lib/upkeep/version.rb +5 -0
  74. data/lib/upkeep-rails.rb +3 -0
  75. data/lib/upkeep.rb +14 -0
  76. data/upkeep-rails.gemspec +54 -0
  77. metadata +320 -0
@@ -0,0 +1,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Upkeep
6
+ module Delivery
7
+ class Transport
8
+ Outcome = Data.define(
9
+ :subscriber_id,
10
+ :status,
11
+ :attempts,
12
+ :queue_depth,
13
+ :envelope_digest,
14
+ :error_class,
15
+ :error_message
16
+ ) do
17
+ def delivered?
18
+ status == :delivered
19
+ end
20
+
21
+ def report
22
+ {
23
+ subscriber_id: subscriber_id,
24
+ status: status,
25
+ attempts: attempts,
26
+ queue_depth: queue_depth,
27
+ envelope_digest: envelope_digest,
28
+ error_class: error_class,
29
+ error_message: error_message
30
+ }.compact
31
+ end
32
+ end
33
+
34
+ DispatchReport = Data.define(:outcomes) do
35
+ def summary
36
+ outcomes.each_with_object(Hash.new(0)) { |outcome, counts| counts[outcome.status] += 1 }
37
+ end
38
+
39
+ def report
40
+ {
41
+ summary: summary,
42
+ outcomes: outcomes.map(&:report)
43
+ }
44
+ end
45
+ end
46
+
47
+ Cleanup = Data.define(:subscriber_id, :status, :dropped_envelopes)
48
+ RetryItem = Data.define(:envelope, :attempts, :error)
49
+
50
+ class Connection
51
+ attr_reader :subscriber_id
52
+
53
+ def initialize(subscriber_id:, adapter:, max_queue_size:, retry_limit:)
54
+ @subscriber_id = subscriber_id
55
+ @adapter = adapter
56
+ @max_queue_size = max_queue_size
57
+ @retry_limit = retry_limit
58
+ @queue = []
59
+ end
60
+
61
+ def deliver(envelope)
62
+ deliver_envelope(envelope, attempts: 0)
63
+ end
64
+
65
+ def retry_pending
66
+ pending = @queue
67
+ @queue = []
68
+
69
+ pending.map { |item| deliver_envelope(item.envelope, attempts: item.attempts) }
70
+ end
71
+
72
+ def disconnect
73
+ dropped = @queue.size
74
+ @queue = []
75
+ dropped
76
+ end
77
+
78
+ def queue_depth
79
+ @queue.size
80
+ end
81
+
82
+ private
83
+
84
+ attr_reader :adapter, :max_queue_size, :retry_limit
85
+
86
+ def deliver_envelope(envelope, attempts:)
87
+ next_attempt = attempts + 1
88
+ adapter.deliver(envelope)
89
+
90
+ outcome(:delivered, envelope, attempts: next_attempt)
91
+ rescue StandardError => error
92
+ if next_attempt >= retry_limit
93
+ outcome(:dropped_retry_exhausted, envelope, attempts: next_attempt, error: error)
94
+ elsif queue_depth >= max_queue_size
95
+ outcome(:backpressured, envelope, attempts: next_attempt, error: error)
96
+ else
97
+ @queue << RetryItem.new(envelope, next_attempt, error)
98
+ outcome(:queued_retry, envelope, attempts: next_attempt, error: error)
99
+ end
100
+ end
101
+
102
+ def outcome(status, envelope, attempts:, error: nil)
103
+ Outcome.new(
104
+ subscriber_id,
105
+ status,
106
+ attempts,
107
+ queue_depth,
108
+ Transport.envelope_digest(envelope),
109
+ error&.class&.name,
110
+ error&.message
111
+ )
112
+ end
113
+ end
114
+
115
+ class << self
116
+ def envelope_digest(envelope)
117
+ Digest::SHA256.hexdigest(envelope.body)
118
+ end
119
+ end
120
+
121
+ def initialize(max_queue_size: 100, retry_limit: 3)
122
+ @max_queue_size = max_queue_size
123
+ @retry_limit = retry_limit
124
+ @connections = {}
125
+ end
126
+
127
+ def connect(subscriber_id:, adapter:)
128
+ Connection.new(
129
+ subscriber_id: subscriber_id,
130
+ adapter: adapter,
131
+ max_queue_size: max_queue_size,
132
+ retry_limit: retry_limit
133
+ ).tap { |connection| connections[subscriber_id] = connection }
134
+ end
135
+
136
+ def disconnect(subscriber_id)
137
+ connection = connections.delete(subscriber_id)
138
+ dropped = connection&.disconnect || 0
139
+
140
+ Cleanup.new(subscriber_id, :disconnected, dropped)
141
+ end
142
+
143
+ def deliver(batch)
144
+ DispatchReport.new(batch.envelopes.map { |envelope| deliver_envelope(envelope) })
145
+ end
146
+
147
+ def retry_pending(subscriber_id: nil)
148
+ selected_connections = if subscriber_id
149
+ Array(connections[subscriber_id])
150
+ else
151
+ connections.values
152
+ end
153
+
154
+ DispatchReport.new(selected_connections.flat_map(&:retry_pending))
155
+ end
156
+
157
+ def connected?(subscriber_id)
158
+ connections.key?(subscriber_id)
159
+ end
160
+
161
+ def summary
162
+ {
163
+ connections: connections.size,
164
+ queued_envelopes: connections.values.sum(&:queue_depth),
165
+ max_queue_size: max_queue_size,
166
+ retry_limit: retry_limit
167
+ }
168
+ end
169
+
170
+ private
171
+
172
+ attr_reader :connections, :max_queue_size, :retry_limit
173
+
174
+ def deliver_envelope(envelope)
175
+ connection = connections[envelope.subscriber_id]
176
+ return disconnected_outcome(envelope) unless connection
177
+
178
+ connection.deliver(envelope)
179
+ end
180
+
181
+ def disconnected_outcome(envelope)
182
+ Outcome.new(
183
+ envelope.subscriber_id,
184
+ :disconnected,
185
+ 0,
186
+ 0,
187
+ self.class.envelope_digest(envelope),
188
+ nil,
189
+ nil
190
+ )
191
+ end
192
+ end
193
+ end
194
+ end
@@ -0,0 +1,302 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/notifications"
4
+ require "cgi"
5
+
6
+ module Upkeep
7
+ module Delivery
8
+ class TurboStreams
9
+ DELIVERY_ERROR = "delivery_error.upkeep"
10
+
11
+ Stream = Data.define(
12
+ :action,
13
+ :target,
14
+ :target_selector,
15
+ :html,
16
+ :html_digest,
17
+ :identity_signature,
18
+ :shared_stream_name,
19
+ :subscriber_ids,
20
+ :matched_dependency_keys,
21
+ :deoptimization_reason,
22
+ :render_duration_ms
23
+ ) do
24
+ def to_html
25
+ return %(<turbo-stream action="refresh" method="morph" scroll="preserve"></turbo-stream>) if action == "refresh"
26
+
27
+ attributes = %(action="#{CGI.escapeHTML(action)}" targets="#{CGI.escapeHTML(target_selector)}")
28
+ attributes = %(#{attributes} method="morph") if morph_action?
29
+ return %(<turbo-stream #{attributes}></turbo-stream>) if action == "remove"
30
+
31
+ %(<turbo-stream #{attributes}><template>#{html}</template></turbo-stream>)
32
+ end
33
+
34
+ def rendered?
35
+ !%w[remove refresh].include?(action)
36
+ end
37
+
38
+ def morph_action?
39
+ %w[replace update].include?(action)
40
+ end
41
+
42
+ def for_subscriber?(subscriber_id)
43
+ subscriber_ids.include?(subscriber_id)
44
+ end
45
+
46
+ def report
47
+ {
48
+ action: action,
49
+ target: target.to_h,
50
+ target_selector: target_selector,
51
+ identity_signature: identity_signature,
52
+ shared_stream_name: shared_stream_name,
53
+ html_digest: html_digest,
54
+ subscriber_ids: subscriber_ids,
55
+ matched_dependency_keys: matched_dependency_keys,
56
+ deoptimization_reason: deoptimization_reason,
57
+ render_duration_ms: render_duration_ms
58
+ }
59
+ end
60
+ end
61
+
62
+ Envelope = Data.define(:subscriber_id, :streams, :stream_name) do
63
+ def self.subscriber(subscriber_id, streams)
64
+ new(subscriber_id, streams, nil)
65
+ end
66
+
67
+ def self.shared(stream_name, streams)
68
+ new("shared:#{stream_name}", streams, stream_name)
69
+ end
70
+
71
+ def body
72
+ streams.map(&:to_html).join("\n")
73
+ end
74
+
75
+ def report
76
+ {
77
+ subscriber_id: subscriber_id,
78
+ streams: streams.map(&:report)
79
+ }
80
+ end
81
+ end
82
+
83
+ Batch = Data.define(:streams) do
84
+ def envelopes
85
+ return [] if streams.empty?
86
+
87
+ direct_subscriber_id = single_direct_subscriber_id
88
+ return [Envelope.subscriber(direct_subscriber_id, streams)] if direct_subscriber_id
89
+
90
+ shared_envelopes + subscriber_envelopes
91
+ end
92
+
93
+ def envelope_for(subscriber_id)
94
+ Envelope.subscriber(subscriber_id, streams.select { |stream| stream.for_subscriber?(subscriber_id) })
95
+ end
96
+
97
+ def report
98
+ {
99
+ streams: streams.map(&:report),
100
+ envelopes: envelopes.map(&:report)
101
+ }
102
+ end
103
+
104
+ private
105
+
106
+ def single_direct_subscriber_id
107
+ return if streams.any?(&:shared_stream_name)
108
+
109
+ subscriber_ids = streams.flat_map(&:subscriber_ids).uniq
110
+ subscriber_ids.first if subscriber_ids.one?
111
+ end
112
+
113
+ def shared_envelopes
114
+ streams
115
+ .select(&:shared_stream_name)
116
+ .group_by(&:shared_stream_name)
117
+ .map { |stream_name, shared_streams| Envelope.shared(stream_name, shared_streams) }
118
+ end
119
+
120
+ def subscriber_envelopes
121
+ direct_streams = streams.reject(&:shared_stream_name)
122
+ direct_streams
123
+ .flat_map(&:subscriber_ids)
124
+ .uniq
125
+ .sort_by(&:to_s)
126
+ .map { |subscriber_id| Envelope.subscriber(subscriber_id, direct_streams.select { |stream| stream.for_subscriber?(subscriber_id) }) }
127
+ end
128
+ end
129
+
130
+ def build(plan)
131
+ build_many([plan])
132
+ end
133
+
134
+ def build_many(plans)
135
+ payload = {
136
+ plans: plans.size,
137
+ planned_targets: plans.sum { |plan| plan.targets.size }
138
+ }
139
+
140
+ ActiveSupport::Notifications.instrument("build_turbo_streams.upkeep", payload) do
141
+ streams = plans.flat_map { |plan| stream_targets(plan.targets) }.compact
142
+ batch = Batch.new(merge_streams(streams))
143
+ payload.merge!(payload_for(batch, rendered_streams: streams))
144
+ batch
145
+ end
146
+ end
147
+
148
+ private
149
+
150
+ def payload_for(batch, rendered_streams:)
151
+ envelopes = batch.envelopes
152
+
153
+ {
154
+ streams: batch.streams.size,
155
+ envelopes: envelopes.size,
156
+ actions: batch.streams.map(&:action).tally,
157
+ deoptimizations: batch.streams.filter_map(&:deoptimization_reason).tally,
158
+ renders: rendered_streams.count(&:rendered?),
159
+ render_duration_ms: sum_render_duration(rendered_streams),
160
+ payload_bytes: envelopes.sum { |envelope| envelope.body.bytesize }
161
+ }
162
+ end
163
+
164
+ def stream_targets(planned_targets)
165
+ return [] if planned_targets.empty?
166
+ return [stream_for(planned_targets.first)] if planned_targets.one?
167
+
168
+ planned_targets.group_by { |planned_target| render_group_key(planned_target) }.map do |_key, targets|
169
+ stream_for(
170
+ targets.first,
171
+ subscriber_ids: targets.flat_map(&:subscriber_ids),
172
+ matched_dependency_keys: targets.flat_map(&:matched_dependency_keys)
173
+ )
174
+ end
175
+ end
176
+
177
+ # The write that produced these changes has already committed; an isolated render/targeting
178
+ # failure for one target must never propagate back into the writer's request. Rescue per
179
+ # target, surface the failure via instrumentation, and keep delivering the other targets.
180
+ def stream_for(planned_target, subscriber_ids: planned_target.subscriber_ids, matched_dependency_keys: planned_target.matched_dependency_keys)
181
+ build_stream(planned_target, subscriber_ids: subscriber_ids, matched_dependency_keys: matched_dependency_keys)
182
+ rescue StandardError => error
183
+ ActiveSupport::Notifications.instrument(
184
+ DELIVERY_ERROR,
185
+ target: planned_target.target.to_h,
186
+ action: planned_target.action,
187
+ subscription_id: planned_target.subscription_id,
188
+ subscriber_ids: subscriber_ids.uniq.sort_by(&:to_s),
189
+ error_class: error.class.name,
190
+ error_message: error.message
191
+ )
192
+ nil
193
+ end
194
+
195
+ def build_stream(planned_target, subscriber_ids:, matched_dependency_keys:)
196
+ html, render_duration_ms = render_target(planned_target)
197
+
198
+ Stream.new(
199
+ planned_target.action,
200
+ planned_target.target,
201
+ target_selector_for(planned_target.target),
202
+ html,
203
+ Targeting::Extraction.digest_html(html),
204
+ planned_target.identity_signature,
205
+ shared_stream_name_for(planned_target, subscriber_ids: subscriber_ids),
206
+ subscriber_ids.uniq.sort_by(&:to_s),
207
+ matched_dependency_keys.uniq,
208
+ planned_target.deoptimization_reason,
209
+ render_duration_ms
210
+ )
211
+ end
212
+
213
+ def render_target(planned_target)
214
+ return ["", 0.0] if %w[remove refresh].include?(planned_target.action)
215
+
216
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
217
+ html = planned_target.render
218
+ finished_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
219
+ [html, ((finished_at - started_at) * 1000.0).round(3)]
220
+ end
221
+
222
+ def render_group_key(planned_target)
223
+ [
224
+ planned_target.action,
225
+ planned_target.target.kind,
226
+ planned_target.target.id,
227
+ planned_target.identity_signature,
228
+ planned_target.sharing_signature,
229
+ SharedStreams.signature_for(planned_target.recipe),
230
+ planned_target.deoptimization_reason
231
+ ]
232
+ end
233
+
234
+ def merge_streams(streams)
235
+ streams.each_with_object({}) do |stream, indexed_streams|
236
+ key = [
237
+ stream.action,
238
+ stream.target.kind,
239
+ stream.target.id,
240
+ stream.identity_signature,
241
+ stream.shared_stream_name,
242
+ stream.html_digest,
243
+ stream.deoptimization_reason
244
+ ]
245
+ indexed_streams[key] = merge_stream(indexed_streams[key], stream)
246
+ end.values
247
+ end
248
+
249
+ def merge_stream(existing, stream)
250
+ return stream unless existing
251
+
252
+ Stream.new(
253
+ existing.action,
254
+ existing.target,
255
+ existing.target_selector,
256
+ existing.html,
257
+ existing.html_digest,
258
+ existing.identity_signature,
259
+ existing.shared_stream_name,
260
+ (existing.subscriber_ids + stream.subscriber_ids).uniq.sort_by(&:to_s),
261
+ (existing.matched_dependency_keys + stream.matched_dependency_keys).uniq,
262
+ existing.deoptimization_reason,
263
+ (existing.render_duration_ms + stream.render_duration_ms).round(3)
264
+ )
265
+ end
266
+
267
+ def sum_render_duration(streams)
268
+ streams.sum(&:render_duration_ms).round(3)
269
+ end
270
+
271
+ def shared_stream_name_for(planned_target, subscriber_ids:)
272
+ return unless planned_target.sharing_signature
273
+ return unless subscriber_ids.uniq.size > 1
274
+
275
+ SharedStreams.stream_name(
276
+ target: planned_target.shared_stream_target,
277
+ identity_signature: planned_target.identity_signature,
278
+ sharing_signature: planned_target.sharing_signature
279
+ )
280
+ end
281
+
282
+ def target_selector_for(target)
283
+ case target.kind
284
+ when "page"
285
+ %([data-upkeep-page-frame="#{css_escape(target.id)}"])
286
+ when "fragment"
287
+ %([data-upkeep-frame="#{css_escape(target.id)}"])
288
+ when "render_site"
289
+ %([data-upkeep-render-site="#{css_escape(target.id)}"])
290
+ when "dom_id"
291
+ %[##{css_escape(target.id)}]
292
+ else
293
+ raise "unknown delivery target kind: #{target.kind.inspect}"
294
+ end
295
+ end
296
+
297
+ def css_escape(value)
298
+ value.to_s.gsub("\\", "\\\\\\").gsub('"', '\"')
299
+ end
300
+ end
301
+ end
302
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "delivery/turbo_streams"
4
+ require_relative "delivery/transport"
5
+ require_relative "delivery/action_cable_adapter"
6
+ require_relative "delivery/broadcast_transport"
7
+ require_relative "delivery/async_dispatcher"