upkeep-rails 0.2.5-aarch64-linux-gnu

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 (81) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +244 -0
  4. data/docs/drafts/turbo-streams-is-cache-invalidation-you-write-by-hand.md +240 -0
  5. data/docs/how-it-works.md +329 -0
  6. data/docs/plans/sqlglot-active-record-migration.md +211 -0
  7. data/docs/plans/turbo-frame-subscription-composition.md +90 -0
  8. data/docs/spikes/SQLGLOT_VS_AREL_FINDINGS.md +136 -0
  9. data/docs/spikes/query-source-analysis-comparison/FINDINGS.md +169 -0
  10. data/docs/spikes/sqlglot-first-active-record/README.md +26 -0
  11. data/docs/spikes/sqlglot-query-analysis/FINDINGS.md +110 -0
  12. data/docs/spikes/sqlglot-query-analysis/PULSE_1802_FINDINGS.md +95 -0
  13. data/docs/spikes/sqlglot-semantic-bindings/README.md +29 -0
  14. data/lib/generators/upkeep/install/install_generator.rb +192 -0
  15. data/lib/generators/upkeep/install/templates/create_upkeep_subscriptions.rb.erb +49 -0
  16. data/lib/generators/upkeep/install/templates/subscription.js +288 -0
  17. data/lib/generators/upkeep/install/templates/upkeep.rb +65 -0
  18. data/lib/upkeep/active_record_query.rb +248 -0
  19. data/lib/upkeep/capture/request.rb +150 -0
  20. data/lib/upkeep/dag/subscription_shape.rb +244 -0
  21. data/lib/upkeep/dag.rb +454 -0
  22. data/lib/upkeep/delivery/action_cable_adapter.rb +48 -0
  23. data/lib/upkeep/delivery/async_dispatcher.rb +102 -0
  24. data/lib/upkeep/delivery/broadcast_transport.rb +89 -0
  25. data/lib/upkeep/delivery/transport.rb +194 -0
  26. data/lib/upkeep/delivery/turbo_streams.rb +339 -0
  27. data/lib/upkeep/delivery.rb +7 -0
  28. data/lib/upkeep/dependencies.rb +600 -0
  29. data/lib/upkeep/herb/developer_report.rb +135 -0
  30. data/lib/upkeep/herb/manifest_cache.rb +83 -0
  31. data/lib/upkeep/herb/manifest_diff.rb +183 -0
  32. data/lib/upkeep/herb/source_instrumenter.rb +149 -0
  33. data/lib/upkeep/herb/template_manifest.rb +548 -0
  34. data/lib/upkeep/invalidation/collection_append.rb +84 -0
  35. data/lib/upkeep/invalidation/collection_member_replace.rb +78 -0
  36. data/lib/upkeep/invalidation/collection_prepend.rb +84 -0
  37. data/lib/upkeep/invalidation/collection_remove.rb +57 -0
  38. data/lib/upkeep/invalidation/planner.rb +411 -0
  39. data/lib/upkeep/invalidation.rb +7 -0
  40. data/lib/upkeep/rails/action_view_capture.rb +1007 -0
  41. data/lib/upkeep/rails/activation_token.rb +55 -0
  42. data/lib/upkeep/rails/cable/channel.rb +165 -0
  43. data/lib/upkeep/rails/cable/subscriber_identity.rb +361 -0
  44. data/lib/upkeep/rails/cable.rb +4 -0
  45. data/lib/upkeep/rails/client_subscription.rb +65 -0
  46. data/lib/upkeep/rails/cluster_guard.rb +57 -0
  47. data/lib/upkeep/rails/configuration.rb +252 -0
  48. data/lib/upkeep/rails/controller_runtime.rb +187 -0
  49. data/lib/upkeep/rails/install.rb +28 -0
  50. data/lib/upkeep/rails/job_runtime.rb +43 -0
  51. data/lib/upkeep/rails/railtie.rb +44 -0
  52. data/lib/upkeep/rails/replay.rb +244 -0
  53. data/lib/upkeep/rails/testing.rb +259 -0
  54. data/lib/upkeep/rails.rb +466 -0
  55. data/lib/upkeep/replay.rb +462 -0
  56. data/lib/upkeep/runtime.rb +1276 -0
  57. data/lib/upkeep/shared_streams.rb +86 -0
  58. data/lib/upkeep/sql_dependency_analysis.rb +553 -0
  59. data/lib/upkeep/sqlglot/libsqlglot_rust.so +0 -0
  60. data/lib/upkeep/sqlglot/native.rb +121 -0
  61. data/lib/upkeep/sqlglot/native_library.rb +23 -0
  62. data/lib/upkeep/sqlglot.rb +367 -0
  63. data/lib/upkeep/subscriptions/active_record_store.rb +398 -0
  64. data/lib/upkeep/subscriptions/active_record_subscription_persistence.rb +411 -0
  65. data/lib/upkeep/subscriptions/active_registry.rb +80 -0
  66. data/lib/upkeep/subscriptions/base_store.rb +110 -0
  67. data/lib/upkeep/subscriptions/json_snapshot.rb +98 -0
  68. data/lib/upkeep/subscriptions/layered_reverse_index.rb +125 -0
  69. data/lib/upkeep/subscriptions/lookup_instrumentation.rb +32 -0
  70. data/lib/upkeep/subscriptions/persistent_reverse_index.rb +228 -0
  71. data/lib/upkeep/subscriptions/registrar.rb +36 -0
  72. data/lib/upkeep/subscriptions/reverse_index.rb +313 -0
  73. data/lib/upkeep/subscriptions/shape.rb +117 -0
  74. data/lib/upkeep/subscriptions/store.rb +349 -0
  75. data/lib/upkeep/subscriptions.rb +7 -0
  76. data/lib/upkeep/targeting.rb +146 -0
  77. data/lib/upkeep/version.rb +5 -0
  78. data/lib/upkeep-rails.rb +3 -0
  79. data/lib/upkeep.rb +15 -0
  80. data/upkeep-rails.gemspec +66 -0
  81. metadata +327 -0
@@ -0,0 +1,248 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+ require_relative "sqlglot"
5
+ require_relative "sql_dependency_analysis"
6
+
7
+ module Upkeep
8
+ module ActiveRecordQuery
9
+ class OpaqueRelationError < StandardError
10
+ attr_reader :model_name, :table_name, :sql, :reasons
11
+
12
+ def initialize(relation, reasons:)
13
+ @model_name = relation.klass.name
14
+ @table_name = relation.klass.table_name
15
+ @sql = relation.to_sql
16
+ @reasons = reasons
17
+
18
+ super(build_message)
19
+ rescue StandardError => error
20
+ super("Upkeep could not analyze this Active Record relation: #{error.message}")
21
+ end
22
+
23
+ def suggestions
24
+ [
25
+ "Check that the relation emits valid SQL for the configured database adapter.",
26
+ "Report unsupported SQL with the query, adapter, and SQLGlot version.",
27
+ "Render this boundary outside Upkeep reactivity until the SQL is supported."
28
+ ]
29
+ end
30
+
31
+ private
32
+
33
+ def build_message
34
+ <<~MESSAGE
35
+ Upkeep cannot make this Active Record relation reactive because SQLGlot could not prove its dependencies.
36
+
37
+ Relation:
38
+ #{model_name} (#{table_name})
39
+
40
+ SQL:
41
+ #{sql}
42
+
43
+ Why:
44
+ #{reasons.map { |reason| " - #{reason}" }.join("\n")}
45
+
46
+ What to do:
47
+ #{suggestions.map { |suggestion| " - #{suggestion}" }.join("\n")}
48
+ MESSAGE
49
+ end
50
+ end
51
+
52
+ Result = Data.define(
53
+ :primary_table,
54
+ :table_columns,
55
+ :coverage,
56
+ :sql,
57
+ :primary_key,
58
+ :appendable,
59
+ :limit_value,
60
+ :predicates
61
+ ) do
62
+ def tables = table_columns.keys.sort
63
+ def appendable? = appendable
64
+ end
65
+
66
+ module_function
67
+
68
+ def analyze(relation)
69
+ sql = relation.to_sql
70
+ dialect = dialect_for(relation.klass.connection)
71
+ statement = SQLGlot.parse(sql, dialect: dialect)
72
+ schema = Schema.for(relation.klass.connection, statement)
73
+ mapping_schema = SQLGlot::MappingSchema.new(schema, dialect: dialect)
74
+ qualified_statement = SQLGlot.qualify_columns(statement, mapping_schema)
75
+ dependency_statement = SQLDependencyAnalysis.preserve_wildcard_projections(
76
+ statement,
77
+ qualified_statement
78
+ )
79
+ dependency = SQLDependencyAnalysis.analyze(
80
+ dependency_statement,
81
+ schema: schema,
82
+ scope: SQLGlot.build_scope(dependency_statement)
83
+ )
84
+
85
+ Result.new(
86
+ primary_table: relation.klass.table_name,
87
+ table_columns: with_primary_key(
88
+ dependency.table_columns,
89
+ relation.klass.table_name,
90
+ relation.klass.primary_key
91
+ ),
92
+ coverage: :columns,
93
+ sql: sql,
94
+ primary_key: relation.klass.primary_key,
95
+ appendable: dependency.appendable?,
96
+ limit_value: dependency.limit_value,
97
+ predicates: dependency.predicates
98
+ )
99
+ rescue ActiveRecord::StatementInvalid,
100
+ SQLGlot::Error,
101
+ SQLDependencyAnalysis::UnsupportedError,
102
+ KeyError => error
103
+ raise OpaqueRelationError.new(
104
+ relation,
105
+ reasons: [
106
+ "#{error.class}: #{error.message}",
107
+ "dialect: #{safe_dialect(relation)}",
108
+ "SQLGlot: #{SQLGlot.version}"
109
+ ]
110
+ )
111
+ end
112
+
113
+ def analyze_for_write(relation)
114
+ analyze(relation)
115
+ rescue OpaqueRelationError
116
+ table_only_result(relation)
117
+ end
118
+
119
+ def dialect_for(connection)
120
+ adapter = connection.adapter_name.to_s.downcase
121
+
122
+ case adapter
123
+ when /postgres/
124
+ :postgres
125
+ when /mysql|trilogy/
126
+ :mysql
127
+ when /sqlite/
128
+ :sqlite
129
+ when /sqlserver/
130
+ :tsql
131
+ when /oracle/
132
+ :oracle
133
+ else
134
+ raise SQLDependencyAnalysis::UnsupportedError,
135
+ "unsupported Active Record adapter: #{connection.adapter_name}"
136
+ end
137
+ end
138
+
139
+ def with_primary_key(table_columns, primary_table, primary_key)
140
+ columns = table_columns.transform_values(&:dup)
141
+ columns[primary_table.to_s] ||= []
142
+ columns[primary_table.to_s] << primary_key.to_s if primary_key
143
+ columns.transform_values { |names| names.uniq.sort }.sort.to_h
144
+ end
145
+ private_class_method :with_primary_key
146
+
147
+ def table_only_result(relation)
148
+ primary_table = relation.klass.table_name
149
+ primary_key = relation.klass.primary_key
150
+
151
+ Result.new(
152
+ primary_table: primary_table,
153
+ table_columns: {
154
+ primary_table => [primary_key].compact.map(&:to_s)
155
+ },
156
+ coverage: :tables,
157
+ sql: relation.to_sql,
158
+ primary_key: primary_key,
159
+ appendable: false,
160
+ limit_value: relation.limit_value,
161
+ predicates: []
162
+ )
163
+ end
164
+ private_class_method :table_only_result
165
+
166
+ def safe_dialect(relation)
167
+ dialect_for(relation.klass.connection)
168
+ rescue StandardError
169
+ relation.klass.connection.adapter_name
170
+ end
171
+ private_class_method :safe_dialect
172
+
173
+ module Schema
174
+ module_function
175
+
176
+ def for(connection, statement)
177
+ physical_tables(statement).each_with_object({}) do |table, schema|
178
+ lookup_name = table.split(".").last
179
+ columns = begin
180
+ connection.schema_cache.columns(lookup_name).to_h do |column|
181
+ [column.name, column.sql_type]
182
+ end
183
+ rescue ActiveRecord::StatementInvalid
184
+ {}
185
+ end
186
+ schema[table] = columns.freeze unless columns.empty?
187
+ end.freeze
188
+ end
189
+
190
+ def physical_tables(statement)
191
+ collect_sources(statement, visible_ctes: Set.new).uniq.sort
192
+ end
193
+
194
+ def collect_sources(node, visible_ctes:)
195
+ case node
196
+ when Array
197
+ node.flat_map { |child| collect_sources(child, visible_ctes: visible_ctes) }
198
+ when Hash
199
+ return collect_select(node.fetch("Select"), visible_ctes: visible_ctes) if node.key?("Select")
200
+ return collect_table(node.fetch("Table"), visible_ctes: visible_ctes) if node.key?("Table")
201
+
202
+ node.each_value.flat_map do |child|
203
+ collect_sources(child, visible_ctes: visible_ctes)
204
+ end
205
+ else
206
+ []
207
+ end
208
+ end
209
+ private_class_method :collect_sources
210
+
211
+ def collect_select(select, visible_ctes:)
212
+ sources = []
213
+ local_ctes = visible_ctes.dup
214
+
215
+ Array(select["ctes"]).each do |cte|
216
+ cte_scope = local_ctes.dup
217
+ cte_scope << cte["name"] if cte["recursive"] && cte["name"]
218
+ sources.concat(collect_sources(cte["query"], visible_ctes: cte_scope))
219
+ local_ctes << cte["name"] if cte["name"]
220
+ end
221
+
222
+ select
223
+ .reject { |key, _value| key == "ctes" }
224
+ .each_value do |child|
225
+ sources.concat(collect_sources(child, visible_ctes: local_ctes))
226
+ end
227
+
228
+ sources
229
+ end
230
+ private_class_method :collect_select
231
+
232
+ def collect_table(table, visible_ctes:)
233
+ name = [table["catalog"], table["schema"], table["name"]]
234
+ .compact
235
+ .reject(&:empty?)
236
+ .join(".")
237
+ unqualified = [table["catalog"], table["schema"]].all? do |part|
238
+ part.nil? || part.empty?
239
+ end
240
+
241
+ return [] if unqualified && visible_ctes.include?(name)
242
+
243
+ [name]
244
+ end
245
+ private_class_method :collect_table
246
+ end
247
+ end
248
+ end
@@ -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