upkeep-rails 0.1.9

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 (65) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +311 -0
  4. data/docs/how-it-works.md +269 -0
  5. data/lib/generators/upkeep/install/install_generator.rb +127 -0
  6. data/lib/generators/upkeep/install/templates/create_upkeep_subscriptions.rb.erb +49 -0
  7. data/lib/generators/upkeep/install/templates/subscription.js +99 -0
  8. data/lib/generators/upkeep/install/templates/upkeep.rb +63 -0
  9. data/lib/upkeep/active_record_query.rb +392 -0
  10. data/lib/upkeep/capture/request.rb +150 -0
  11. data/lib/upkeep/dag/subscription_shape.rb +244 -0
  12. data/lib/upkeep/dag.rb +370 -0
  13. data/lib/upkeep/delivery/action_cable_adapter.rb +43 -0
  14. data/lib/upkeep/delivery/async_dispatcher.rb +102 -0
  15. data/lib/upkeep/delivery/broadcast_transport.rb +89 -0
  16. data/lib/upkeep/delivery/transport.rb +194 -0
  17. data/lib/upkeep/delivery/turbo_streams.rb +302 -0
  18. data/lib/upkeep/delivery.rb +7 -0
  19. data/lib/upkeep/dependencies.rb +550 -0
  20. data/lib/upkeep/herb/developer_report.rb +135 -0
  21. data/lib/upkeep/herb/manifest_cache.rb +83 -0
  22. data/lib/upkeep/herb/manifest_diff.rb +183 -0
  23. data/lib/upkeep/herb/source_instrumenter.rb +149 -0
  24. data/lib/upkeep/herb/template_manifest.rb +518 -0
  25. data/lib/upkeep/invalidation/collection_append.rb +84 -0
  26. data/lib/upkeep/invalidation/collection_member_replace.rb +78 -0
  27. data/lib/upkeep/invalidation/collection_prepend.rb +84 -0
  28. data/lib/upkeep/invalidation/collection_remove.rb +57 -0
  29. data/lib/upkeep/invalidation/planner.rb +360 -0
  30. data/lib/upkeep/invalidation.rb +7 -0
  31. data/lib/upkeep/rails/action_view_capture.rb +920 -0
  32. data/lib/upkeep/rails/activation_token.rb +55 -0
  33. data/lib/upkeep/rails/cable/channel.rb +143 -0
  34. data/lib/upkeep/rails/cable/subscriber_identity.rb +341 -0
  35. data/lib/upkeep/rails/cable.rb +4 -0
  36. data/lib/upkeep/rails/client_subscription.rb +45 -0
  37. data/lib/upkeep/rails/configuration.rb +245 -0
  38. data/lib/upkeep/rails/controller_runtime.rb +154 -0
  39. data/lib/upkeep/rails/delivery_job.rb +29 -0
  40. data/lib/upkeep/rails/install.rb +28 -0
  41. data/lib/upkeep/rails/railtie.rb +50 -0
  42. data/lib/upkeep/rails/replay.rb +197 -0
  43. data/lib/upkeep/rails/testing.rb +258 -0
  44. data/lib/upkeep/rails.rb +370 -0
  45. data/lib/upkeep/replay.rb +439 -0
  46. data/lib/upkeep/runtime.rb +1202 -0
  47. data/lib/upkeep/shared_streams.rb +72 -0
  48. data/lib/upkeep/subscriptions/active_record_store.rb +387 -0
  49. data/lib/upkeep/subscriptions/active_record_subscription_persistence.rb +407 -0
  50. data/lib/upkeep/subscriptions/active_registry.rb +87 -0
  51. data/lib/upkeep/subscriptions/async_durable_writer.rb +131 -0
  52. data/lib/upkeep/subscriptions/json_snapshot.rb +98 -0
  53. data/lib/upkeep/subscriptions/layered_reverse_index.rb +129 -0
  54. data/lib/upkeep/subscriptions/persistent_reverse_index.rb +223 -0
  55. data/lib/upkeep/subscriptions/registrar.rb +36 -0
  56. data/lib/upkeep/subscriptions/reverse_index.rb +301 -0
  57. data/lib/upkeep/subscriptions/shape.rb +116 -0
  58. data/lib/upkeep/subscriptions/store.rb +375 -0
  59. data/lib/upkeep/subscriptions.rb +7 -0
  60. data/lib/upkeep/targeting.rb +135 -0
  61. data/lib/upkeep/version.rb +5 -0
  62. data/lib/upkeep-rails.rb +3 -0
  63. data/lib/upkeep.rb +14 -0
  64. data/upkeep-rails.gemspec +54 -0
  65. metadata +308 -0
@@ -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
data/lib/upkeep/dag.rb ADDED
@@ -0,0 +1,370 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+
6
+ module Upkeep
7
+ module DAG
8
+ Node = Data.define(:id, :kind, :payload)
9
+ Edge = Data.define(:from, :to, :reason)
10
+
11
+ class Graph
12
+ attr_reader :nodes, :edges, :version
13
+
14
+ def initialize
15
+ @nodes = {}
16
+ @edges = []
17
+ @version = 0
18
+ reset_indexes!
19
+ end
20
+
21
+ def add_node(id, kind:, payload: {})
22
+ return nodes.fetch(id) if nodes.key?(id)
23
+
24
+ @version += 1
25
+ nodes[id] = Node.new(id, kind, payload)
26
+ end
27
+
28
+ def add_edge(from, to, reason:)
29
+ key = edge_key(from, to, reason)
30
+ return false if @edge_keys[key]
31
+
32
+ @edge_keys[key] = true
33
+ @version += 1
34
+ edge = Edge.new(from, to, reason)
35
+ edges << edge
36
+ @outgoing_edges_by_from[from] << edge
37
+ @incoming_edges_by_to[to] << edge
38
+ @dependency_owner_ids_by_node[to] << from if reason == :depends_on
39
+ true
40
+ end
41
+
42
+ def add_dependency(owner_id, dependency)
43
+ dependency_cache_key = dependency.cache_key
44
+ dependency_cache_keys = @dependency_cache_keys_by_node[owner_id]
45
+ return false if dependency_cache_keys.key?(dependency_cache_key)
46
+
47
+ add_node(owner_id, kind: :unknown) unless nodes.key?(owner_id)
48
+ add_node(dependency_cache_key, kind: :dependency, payload: dependency)
49
+ add_edge(owner_id, dependency_cache_key, reason: :depends_on)
50
+
51
+ dependency_cache_keys[dependency_cache_key] = true
52
+ @dependencies_by_node[owner_id] << dependency
53
+ true
54
+ end
55
+
56
+ def dependencies_for(node_id)
57
+ @dependencies_by_node[node_id]
58
+ end
59
+
60
+ def node(id)
61
+ nodes.fetch(id)
62
+ end
63
+
64
+ def node?(id)
65
+ nodes.key?(id)
66
+ end
67
+
68
+ def outgoing_edges(from, reason: nil)
69
+ indexed_edges = @outgoing_edges_by_from.fetch(from, [])
70
+ return indexed_edges.dup unless reason
71
+
72
+ indexed_edges.select { |edge| edge.reason == reason }
73
+ end
74
+
75
+ def incoming_edges(to, reason: nil)
76
+ indexed_edges = @incoming_edges_by_to.fetch(to, [])
77
+ return indexed_edges.dup unless reason
78
+
79
+ indexed_edges.select { |edge| edge.reason == reason }
80
+ end
81
+
82
+ def dependency_owner_ids(dependency_node_id)
83
+ @dependency_owner_ids_by_node.fetch(dependency_node_id, []).dup
84
+ end
85
+
86
+ def dependency_node_ids_matching(changes)
87
+ dependency_nodes.filter_map do |node|
88
+ node.id if changes.any? { |change| node.payload.matches_change?(change) }
89
+ end
90
+ end
91
+
92
+ def nearest_frame_nodes_from(node_id)
93
+ current = node(node_id)
94
+ return [current] if current.kind == :frame
95
+
96
+ queue = outgoing_edges(node_id, reason: :contains).map(&:to)
97
+ visited = {}
98
+ frames = []
99
+
100
+ until queue.empty?
101
+ id = queue.shift
102
+ next if visited[id]
103
+
104
+ visited[id] = true
105
+ current = node(id)
106
+ if current.kind == :frame
107
+ frames << current
108
+ else
109
+ queue.concat(outgoing_edges(id, reason: :contains).map(&:to))
110
+ end
111
+ end
112
+
113
+ frames
114
+ end
115
+
116
+ def ancestor_node_ids(node_id)
117
+ ancestors = []
118
+ current = node_id
119
+
120
+ while (edge = incoming_edges(current, reason: :contains).first)
121
+ ancestors << edge.from
122
+ current = edge.from
123
+ end
124
+
125
+ ancestors
126
+ end
127
+
128
+ def contained_by?(descendant_id, ancestor_id)
129
+ ancestor_node_ids(descendant_id).include?(ancestor_id)
130
+ end
131
+
132
+ def contained_node_ids(node_id)
133
+ ids = []
134
+ queue = [node_id]
135
+ visited = {}
136
+
137
+ until queue.empty?
138
+ id = queue.shift
139
+ next if visited[id]
140
+
141
+ visited[id] = true
142
+ ids << id
143
+ queue.concat(outgoing_edges(id, reason: :contains).map(&:to))
144
+ end
145
+
146
+ ids
147
+ end
148
+
149
+ def frame_nodes
150
+ nodes.values.select { |node| node.kind == :frame }
151
+ end
152
+
153
+ def dependency_nodes
154
+ nodes.values.select { |node| node.kind == :dependency }
155
+ end
156
+
157
+ def summary
158
+ {
159
+ nodes: nodes.size,
160
+ edges: edges.size,
161
+ frames: frame_nodes.size,
162
+ manifest_attached_frames: frame_nodes.count { |node| node.payload[:manifest_path] },
163
+ dependencies: dependency_nodes.size,
164
+ containment_edges: edges.count { |edge| edge.reason == :contains },
165
+ dependency_edges: edges.count { |edge| edge.reason == :depends_on },
166
+ replay_recipes: frame_nodes.count { |node| node.payload[:recipe] },
167
+ replay_recipe_kinds: frame_nodes.filter_map { |node| node.payload[:recipe]&.kind }.map(&:to_s).uniq.sort,
168
+ dependency_sources: dependency_nodes.map { |node| node.payload.source.to_s }.uniq.sort
169
+ }
170
+ end
171
+
172
+ def report
173
+ {
174
+ summary: summary,
175
+ frames: frame_reports,
176
+ dependencies: dependency_reports,
177
+ edges: edges.map(&:to_h)
178
+ }
179
+ end
180
+
181
+ def to_h(dependencies: :all)
182
+ serialized_nodes = serializable_nodes(dependencies: dependencies)
183
+ node_ids = serialized_nodes.to_h { |node| [node.id, true] }
184
+
185
+ {
186
+ nodes: serialized_nodes.map { |node| serialize_node(node) },
187
+ edges: edges.select { |edge| node_ids[edge.from] && node_ids[edge.to] }.map(&:to_h)
188
+ }
189
+ end
190
+
191
+ def self.from_h(snapshot)
192
+ snapshot = symbolize_keys(snapshot)
193
+ graph = new
194
+ graph.nodes.clear
195
+ graph.edges.clear
196
+ graph.send(:reset_indexes!)
197
+
198
+ snapshot.fetch(:nodes).each do |node_snapshot|
199
+ node_snapshot = symbolize_keys(node_snapshot)
200
+ kind = node_snapshot.fetch(:kind).to_sym
201
+ graph.nodes[node_snapshot.fetch(:id)] = Node.new(
202
+ node_snapshot.fetch(:id),
203
+ kind,
204
+ deserialize_payload(kind, node_snapshot.fetch(:payload))
205
+ )
206
+ end
207
+
208
+ snapshot.fetch(:edges).each do |edge_snapshot|
209
+ edge_snapshot = symbolize_keys(edge_snapshot)
210
+ graph.add_edge(
211
+ edge_snapshot.fetch(:from),
212
+ edge_snapshot.fetch(:to),
213
+ reason: edge_snapshot.fetch(:reason).to_sym
214
+ )
215
+ end
216
+
217
+ graph.send(:rebuild_dependency_index)
218
+ graph
219
+ end
220
+
221
+ def frame_reports
222
+ frame_nodes.map do |node|
223
+ {
224
+ id: node.id,
225
+ kind: node.payload.fetch(:kind),
226
+ template: node.payload[:template],
227
+ site_id: node.payload[:site_id],
228
+ manifest_path: node.payload[:manifest_path],
229
+ manifest_fingerprint: node.payload[:manifest_fingerprint],
230
+ locals: node.payload[:locals],
231
+ contains: outgoing_edges(node.id, reason: :contains).map(&:to),
232
+ dependencies: dependencies_for(node.id).map(&:to_h),
233
+ replay_recipe: recipe_report(node.payload[:recipe])
234
+ }.compact
235
+ end
236
+ end
237
+
238
+ def recipe_report(recipe)
239
+ return unless recipe
240
+
241
+ snapshot = recipe.to_h
242
+ replay = snapshot[:replay] || snapshot["replay"] || {}
243
+ replay_json = JSON.generate(replay)
244
+ {
245
+ kind: recipe.kind.to_s,
246
+ target_kind: recipe.target_kind,
247
+ target_id: recipe.target_id,
248
+ runtime: recipe.runtime,
249
+ template: recipe.template,
250
+ replay: {
251
+ type: recipe.replay.respond_to?(:type) ? recipe.replay.type : nil,
252
+ keys: replay.keys.map(&:to_s).sort,
253
+ bytes: replay_json.bytesize,
254
+ digest: Digest::SHA256.hexdigest(replay_json)
255
+ }.compact
256
+ }.compact
257
+ end
258
+
259
+ def dependency_reports
260
+ dependency_nodes.map do |node|
261
+ {
262
+ id: node.id,
263
+ dependency: node.payload.to_h,
264
+ owners: dependency_owner_ids(node.id)
265
+ }
266
+ end
267
+ end
268
+
269
+ private
270
+
271
+ def serializable_nodes(dependencies:)
272
+ case dependencies
273
+ when :all
274
+ nodes.values
275
+ when :identity
276
+ nodes.values.select { |node| node.kind != :dependency || node.payload.identity? }
277
+ else
278
+ raise ArgumentError, "unsupported graph dependency serialization mode: #{dependencies.inspect}"
279
+ end
280
+ end
281
+
282
+ def reset_indexes!
283
+ @edge_keys = {}
284
+ @outgoing_edges_by_from = Hash.new { |hash, key| hash[key] = [] }
285
+ @incoming_edges_by_to = Hash.new { |hash, key| hash[key] = [] }
286
+ @dependency_owner_ids_by_node = Hash.new { |hash, key| hash[key] = [] }
287
+ @dependencies_by_node = Hash.new { |hash, key| hash[key] = [] }
288
+ @dependency_cache_keys_by_node = Hash.new { |hash, key| hash[key] = {} }
289
+ end
290
+
291
+ def rebuild_dependency_index
292
+ @dependencies_by_node = Hash.new { |hash, key| hash[key] = [] }
293
+ @dependency_cache_keys_by_node = Hash.new { |hash, key| hash[key] = {} }
294
+
295
+ edges.each do |edge|
296
+ next unless edge.reason == :depends_on
297
+
298
+ dependency = nodes.fetch(edge.to).payload
299
+ dependency_cache_keys = @dependency_cache_keys_by_node[edge.from]
300
+ next if dependency_cache_keys.key?(dependency.cache_key)
301
+
302
+ dependency_cache_keys[dependency.cache_key] = true
303
+ @dependencies_by_node[edge.from] << dependency
304
+ end
305
+ end
306
+
307
+ def edge_key(from, to, reason)
308
+ [from, to, reason]
309
+ end
310
+
311
+ class << self
312
+ def deserialize_payload(kind, payload)
313
+ payload = symbolize_keys(payload)
314
+
315
+ case kind
316
+ when :dependency
317
+ Dependencies.from_h(payload)
318
+ when :frame
319
+ deserialize_frame_payload(payload)
320
+ else
321
+ payload
322
+ end
323
+ end
324
+
325
+ def deserialize_frame_payload(payload)
326
+ payload.each_with_object({}) do |(key, value), frame_payload|
327
+ frame_payload[key] = key == :recipe && value ? Replay::Recipe.from_h(value) : value
328
+ end
329
+ end
330
+
331
+ def symbolize_keys(value)
332
+ case value
333
+ when Hash
334
+ value.each_with_object({}) do |(key, nested_value), result|
335
+ normalized_key = key.respond_to?(:to_sym) ? key.to_sym : key
336
+ result[normalized_key] = symbolize_keys(nested_value)
337
+ end
338
+ when Array
339
+ value.map { |nested_value| symbolize_keys(nested_value) }
340
+ else
341
+ value
342
+ end
343
+ end
344
+ end
345
+
346
+ def serialize_node(node)
347
+ {
348
+ id: node.id,
349
+ kind: node.kind,
350
+ payload: serialize_payload(node)
351
+ }
352
+ end
353
+
354
+ def serialize_payload(node)
355
+ case node.kind
356
+ when :dependency
357
+ node.payload.to_h
358
+ when :frame
359
+ node.payload.each_with_object({}) do |(key, value), frame_payload|
360
+ frame_payload[key] = key == :recipe && value ? value.to_h : value
361
+ end
362
+ else
363
+ node.payload
364
+ end
365
+ end
366
+ end
367
+ end
368
+ end
369
+
370
+ require_relative "dag/subscription_shape"
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/notifications"
4
+ require "digest"
5
+
6
+ module Upkeep
7
+ module Delivery
8
+ class ActionCableAdapter
9
+ STREAM_PREFIX = "upkeep:subscriber"
10
+
11
+ def self.stream_name_for(subscriber_id)
12
+ "#{STREAM_PREFIX}:#{Digest::SHA256.hexdigest(subscriber_id.to_s)[0, 32]}"
13
+ end
14
+
15
+ def initialize(server: default_server)
16
+ @server = server
17
+ end
18
+
19
+ def deliver(envelope)
20
+ stream_name = envelope.stream_name || self.class.stream_name_for(envelope.subscriber_id)
21
+ payload = {
22
+ subscriber_id: envelope.subscriber_id,
23
+ stream_name: stream_name,
24
+ envelope_digest: Transport.envelope_digest(envelope),
25
+ bytesize: envelope.body.bytesize
26
+ }
27
+
28
+ ActiveSupport::Notifications.instrument("deliver.upkeep", payload) do
29
+ server.broadcast(stream_name, envelope.body)
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ attr_reader :server
36
+
37
+ def default_server
38
+ require "action_cable"
39
+ ::ActionCable.server
40
+ end
41
+ end
42
+ end
43
+ end