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,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Upkeep
4
+ module Capture
5
+ RequestSignature = Data.define(:controller, :action, :method, :fullpath)
6
+
7
+ RequestResult = Data.define(
8
+ :action_result,
9
+ :html,
10
+ :recorder,
11
+ :response_status,
12
+ :response_content_type,
13
+ :response_media_type,
14
+ :response_successful,
15
+ :signature,
16
+ :timings,
17
+ :counters
18
+ ) do
19
+ def successful?
20
+ !!response_successful
21
+ end
22
+
23
+ def html_response?
24
+ response_media_type == "text/html" ||
25
+ response_content_type.to_s.start_with?("text/html")
26
+ end
27
+ end
28
+
29
+ module Request
30
+ module_function
31
+
32
+ def call(controller, profile: false)
33
+ timings = {}
34
+ counters = {}
35
+ action_result, recorder = measure(timings, :action_ms) do
36
+ if profile
37
+ profile_action(timings, counters) do
38
+ Runtime::Observation.capture_request(profile: true) { yield }
39
+ end
40
+ else
41
+ Runtime::Observation.capture_request { yield }
42
+ end
43
+ end
44
+ timings.merge!(recorder.profile_timings)
45
+ counters.merge!(recorder.profile_counts)
46
+ html = measure(timings, :response_body_ms) { response_body_html(controller.response.body) }
47
+ signature = measure(timings, :signature_ms) { signature_for(controller) }
48
+ RequestResult.new(
49
+ action_result,
50
+ html,
51
+ recorder,
52
+ controller.response.status,
53
+ controller.response.content_type,
54
+ controller.response.media_type,
55
+ controller.response.successful?,
56
+ signature,
57
+ timings,
58
+ counters
59
+ )
60
+ end
61
+
62
+ def profile_action(timings, counters)
63
+ collector = ActionProfiler.new
64
+ collector.capture { yield }.tap do
65
+ timings.merge!(collector.timings)
66
+ counters.merge!(collector.counters)
67
+ end
68
+ end
69
+
70
+ def signature_for(controller)
71
+ request = controller.request
72
+ RequestSignature.new(
73
+ controller.class.name,
74
+ controller.action_name,
75
+ request.request_method,
76
+ request.fullpath
77
+ )
78
+ end
79
+
80
+ def response_body_html(body)
81
+ case body
82
+ when String
83
+ body
84
+ when Array
85
+ body.join
86
+ else
87
+ return body.body.join if body.respond_to?(:body) && body.body.respond_to?(:join)
88
+ return body.to_a.join if body.respond_to?(:to_a)
89
+
90
+ body.to_s
91
+ end
92
+ end
93
+
94
+ def measure(timings, key)
95
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
96
+ yield
97
+ ensure
98
+ timings[key] = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000.0).round(3)
99
+ end
100
+
101
+ class ActionProfiler
102
+ EVENT_MAP = {
103
+ "sql.active_record" => :sql,
104
+ "render_template.action_view" => :render_template,
105
+ "render_partial.action_view" => :render_partial,
106
+ "render_collection.action_view" => :render_collection
107
+ }.freeze
108
+
109
+ attr_reader :timings, :counters
110
+
111
+ def initialize
112
+ @thread = Thread.current
113
+ @timings = Hash.new(0.0)
114
+ @counters = Hash.new(0)
115
+ end
116
+
117
+ def capture
118
+ callback = lambda do |name, started, finished, unique_id, payload|
119
+ next unless Thread.current.equal?(@thread)
120
+
121
+ event = ActiveSupport::Notifications::Event.new(name, started, finished, unique_id, payload)
122
+ record(event)
123
+ end
124
+
125
+ ActiveSupport::Notifications.subscribed(callback, /\A(sql\.active_record|render_(template|partial|collection)\.action_view)\z/) do
126
+ yield
127
+ end
128
+ ensure
129
+ @timings.transform_values! { |value| value.round(3) }
130
+ end
131
+
132
+ private
133
+
134
+ def record(event)
135
+ key = EVENT_MAP[event.name]
136
+ return unless key
137
+ return if ignored_sql?(event)
138
+
139
+ @timings[:"#{key}_ms"] += event.duration
140
+ @counters[:"#{key}_count"] += 1
141
+ @timings[:view_ms] += event.duration if event.name.end_with?(".action_view")
142
+ end
143
+
144
+ def ignored_sql?(event)
145
+ event.name == "sql.active_record" && event.payload[:name] == "SCHEMA"
146
+ end
147
+ end
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,244 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require_relative "../shared_streams"
5
+ require_relative "../version"
6
+
7
+ module Upkeep
8
+ module DAG
9
+ class SubscriptionShape
10
+ DIGEST_SCOPE = "upkeep-subscription-shape"
11
+ FRAME_PAYLOAD_SHAPE_IGNORED_KEYS = %i[manifest recipe].freeze
12
+
13
+ attr_reader :signature
14
+
15
+ def self.from_graph(graph, request_signature: nil)
16
+ new(signature: signature_for_terms(request_signature, graph_terms(graph)))
17
+ end
18
+
19
+ def self.from_components(graph_component, request_signature: nil)
20
+ new(signature: signature_for_terms(request_signature, terms_for_component(graph_component)))
21
+ end
22
+
23
+ def self.from_terms(graph_terms, request_signature: nil)
24
+ new(signature: signature_for_terms(request_signature, graph_terms))
25
+ end
26
+
27
+ def self.from_trace_digest(trace_digest, request_signature: nil)
28
+ new(signature: signature_for_trace_digest(request_signature, trace_digest))
29
+ end
30
+
31
+ def self.signature_for_terms(request_signature, graph_terms)
32
+ digest = Digest::SHA256.new
33
+ digest.update(DIGEST_SCOPE)
34
+ digest.update("\0")
35
+ digest.update(Upkeep::VERSION)
36
+ digest.update("\0")
37
+ digest.update(canonical_value(request_signature_component(request_signature)))
38
+ %i[frames dependencies contains].each do |group|
39
+ digest.update("\0")
40
+ digest.update(group.to_s)
41
+ Array(graph_terms[group]).each do |term|
42
+ digest.update("\0")
43
+ digest.update(term)
44
+ end
45
+ end
46
+ digest.hexdigest
47
+ end
48
+
49
+ def self.signature_for_trace_digest(request_signature, trace_digest)
50
+ digest = Digest::SHA256.new
51
+ digest.update(DIGEST_SCOPE)
52
+ digest.update("\0")
53
+ digest.update(Upkeep::VERSION)
54
+ digest.update("\0")
55
+ digest.update(canonical_value(request_signature_component(request_signature)))
56
+ digest.update("\0")
57
+ digest.update(trace_digest)
58
+ digest.hexdigest
59
+ end
60
+
61
+ def self.request_signature_component(signature)
62
+ return nil unless signature
63
+
64
+ signature.respond_to?(:to_h) ? signature.to_h : signature
65
+ end
66
+
67
+ def self.graph_component(graph)
68
+ {
69
+ frames: graph.frame_nodes.map { |node| frame_component(node.id, node.payload) }.sort_by { |component| component.fetch(:id).to_s },
70
+ dependencies: graph.dependency_nodes.map { |node| dependency_component(graph, node) }.sort_by { |component| component.fetch(:id).inspect },
71
+ contains: graph.edges
72
+ .select { |edge| edge.reason == :contains }
73
+ .map { |edge| [edge.from, edge.to] }
74
+ .sort_by(&:inspect)
75
+ }
76
+ end
77
+
78
+ def self.graph_terms(graph)
79
+ {
80
+ frames: graph.frame_nodes.map { |node| frame_term(node.id, node.payload) },
81
+ dependencies: graph.dependency_nodes.map { |node| dependency_term(node.id, node.payload, graph.dependency_owner_ids(node.id)) },
82
+ contains: graph.edges
83
+ .select { |edge| edge.reason == :contains }
84
+ .map { |edge| contains_term(edge.from, edge.to) }
85
+ }
86
+ end
87
+
88
+ def self.terms_for_component(graph_component)
89
+ {
90
+ frames: graph_component.fetch(:frames).map { |component| canonical_term(:frame, component.fetch(:id), component.fetch(:payload)) },
91
+ dependencies: graph_component.fetch(:dependencies).map do |component|
92
+ canonical_term(:dependency, component.fetch(:id), component.fetch(:dependency), component.fetch(:owners))
93
+ end,
94
+ contains: graph_component.fetch(:contains).map { |from, to| contains_term(from, to) }
95
+ }
96
+ end
97
+
98
+ def self.frame_component(id, payload)
99
+ {
100
+ id: id,
101
+ payload: frame_payload_component(payload)
102
+ }
103
+ end
104
+
105
+ def self.frame_term(id, payload)
106
+ canonical_term(:frame, id, frame_payload_component(payload))
107
+ end
108
+
109
+ def self.frame_payload_component(payload)
110
+ component = payload.reject do |key, _value|
111
+ key.respond_to?(:to_sym) && FRAME_PAYLOAD_SHAPE_IGNORED_KEYS.include?(key.to_sym)
112
+ end
113
+ component = shape_value(component)
114
+ recipe = payload[:recipe] || payload["recipe"]
115
+ kind = payload[:kind] || payload["kind"]
116
+ if recipe && kind.to_s == "render_site"
117
+ component[:shared_stream_signature] = SharedStreams.signature_for(recipe)
118
+ end
119
+ component
120
+ end
121
+
122
+ def self.dependency_component(graph, node)
123
+ {
124
+ id: node.id,
125
+ dependency: shape_value(node.payload.to_h),
126
+ owners: graph.dependency_owner_ids(node.id).sort_by(&:to_s)
127
+ }
128
+ end
129
+
130
+ def self.dependency_term(id, dependency, owners)
131
+ canonical_term(:dependency, id, dependency.to_h, owners.sort_by(&:to_s))
132
+ end
133
+
134
+ def self.contains_term(from, to)
135
+ canonical_term(:contains, from, to)
136
+ end
137
+
138
+ def self.shape_value(value)
139
+ case value
140
+ when Hash
141
+ value.keys.sort_by(&:to_s).to_h { |key| [key, shape_value(value.fetch(key))] }
142
+ when Array
143
+ value.map { |item| shape_value(item) }
144
+ else
145
+ value.respond_to?(:to_h) ? shape_value(value.to_h) : value
146
+ end
147
+ end
148
+
149
+ def self.canonical_term(*parts)
150
+ parts.map { |part| canonical_value(part) }.join("\0")
151
+ end
152
+
153
+ def self.canonical_value(value)
154
+ shape_value(value).inspect
155
+ end
156
+
157
+ def initialize(signature:)
158
+ @signature = signature
159
+ end
160
+
161
+ class Trace
162
+ def initialize(graph_version:)
163
+ @graph_version = graph_version
164
+ @seen_frame_ids = {}
165
+ @seen_dependency_keys = {}
166
+ @seen_dependency_owner_ids_by_key = Hash.new { |owners, dependency_key| owners[dependency_key] = {} }
167
+ @seen_contains_edges = {}
168
+ @digest = Digest::SHA256.new
169
+ @digest.update("subscription-shape-trace")
170
+ @invalid = false
171
+ @recorded = false
172
+ end
173
+
174
+ def synchronized_with?(graph)
175
+ !@invalid && @graph_version == graph.version
176
+ end
177
+
178
+ def invalidate!
179
+ @invalid = true
180
+ end
181
+
182
+ def record_frame(frame_id, metadata, parent_id:, graph_version:)
183
+ return if @invalid
184
+
185
+ unless @seen_frame_ids.key?(frame_id)
186
+ @seen_frame_ids[frame_id] = true
187
+ record_digest_term(:frame, SubscriptionShape.frame_term(frame_id, metadata))
188
+ end
189
+ edge_key = [parent_id, frame_id]
190
+ unless @seen_contains_edges.key?(edge_key)
191
+ @seen_contains_edges[edge_key] = true
192
+ record_digest_term(:contains, SubscriptionShape.contains_term(parent_id, frame_id))
193
+ end
194
+ @recorded = true
195
+ @graph_version = graph_version
196
+ end
197
+
198
+ def record_dependency(owner_id, dependency, graph_version:)
199
+ return if @invalid
200
+
201
+ dependency_cache_key = dependency.cache_key
202
+ unless @seen_dependency_keys.key?(dependency_cache_key)
203
+ @seen_dependency_keys[dependency_cache_key] = true
204
+ dependency_payload = SubscriptionShape.shape_value(dependency.to_h)
205
+ record_digest_term(:dependency, SubscriptionShape.canonical_term(:dependency, dependency_cache_key, dependency_payload))
206
+ end
207
+ unless @seen_dependency_owner_ids_by_key[dependency_cache_key].key?(owner_id)
208
+ @seen_dependency_owner_ids_by_key[dependency_cache_key][owner_id] = true
209
+ record_digest_term(:dependency_owner, SubscriptionShape.canonical_term(:dependency_owner, dependency_cache_key, owner_id))
210
+ end
211
+ @recorded = true
212
+ @graph_version = graph_version
213
+ end
214
+
215
+ def covers?(graph)
216
+ synchronized_with?(graph) && (recorded? || graph_shape_empty?(graph))
217
+ end
218
+
219
+ def subscription_shape(request_signature: nil)
220
+ SubscriptionShape.from_trace_digest(@digest.hexdigest, request_signature: request_signature)
221
+ end
222
+
223
+ private
224
+
225
+ def recorded?
226
+ @recorded
227
+ end
228
+
229
+ def graph_shape_empty?(graph)
230
+ graph.frame_nodes.empty? &&
231
+ graph.dependency_nodes.empty? &&
232
+ graph.edges.none? { |edge| edge.reason == :contains }
233
+ end
234
+
235
+ def record_digest_term(kind, term)
236
+ @digest.update("\0")
237
+ @digest.update(kind.to_s)
238
+ @digest.update("\0")
239
+ @digest.update(term)
240
+ end
241
+ end
242
+ end
243
+ end
244
+ end