haveapi 0.30.1 → 0.31.0

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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +1 -0
  3. data/Gemfile +1 -0
  4. data/benchmarks/Gemfile +30 -0
  5. data/benchmarks/Gemfile.lock +296 -0
  6. data/benchmarks/documentation.rb +277 -0
  7. data/benchmarks/endpoints.rb +786 -0
  8. data/benchmarks/performance_budgets.json +1354 -0
  9. data/benchmarks/performance_compare.rb +678 -0
  10. data/benchmarks/shell.nix +21 -0
  11. data/doc/client-examples.md +179 -26
  12. data/doc/performance.md +310 -0
  13. data/haveapi.gemspec +1 -1
  14. data/lib/haveapi/client_examples/ts_generated_client.rb +36 -0
  15. data/lib/haveapi/documentation_clients.rb +49 -6
  16. data/lib/haveapi/documentation_context.rb +42 -12
  17. data/lib/haveapi/version.rb +1 -1
  18. data/lib/haveapi/views/version_page.erb +2 -2
  19. data/spec/client_examples/context_performance_spec.rb +72 -0
  20. data/spec/client_examples/context_spec.rb +206 -0
  21. data/spec/client_examples/profile_mount_spec.rb +45 -0
  22. data/spec/client_examples/ts_generated_client_spec.rb +53 -0
  23. data/spec/client_examples/typescript_profiles_spec.rb +113 -0
  24. data/spec/documentation/client_examples_spec.rb +2 -2
  25. data/spec/documentation/performance_spec.rb +79 -0
  26. data/spec/performance/comparator_spec.rb +472 -0
  27. data/spec/performance/endpoint_contract_spec.rb +226 -0
  28. data/spec/performance/endpoint_scaling_spec.rb +82 -0
  29. data/spec/performance/event_baseline_spec.rb +407 -0
  30. data/spec/performance/json_activation_spec.rb +151 -0
  31. data/spec/performance/release_lineage_spec.rb +470 -0
  32. data/spec/performance/runner_spec.rb +610 -0
  33. data/spec/performance/sqlite_spec.rb +194 -0
  34. data/spec/performance/support/live_worker.rb +44 -0
  35. data/spec/performance/support/samples.rb +72 -0
  36. data/test_support/documentation_performance_fixture.rb +106 -0
  37. data/test_support/performance_endpoint_fixture.rb +350 -0
  38. data/test_support/performance_sqlite_fixture.rb +384 -0
  39. metadata +29 -3
@@ -0,0 +1,786 @@
1
+ require_relative 'performance_compare'
2
+ require 'base64'
3
+ require 'fileutils'
4
+ require 'etc'
5
+ require 'open3'
6
+ require 'optparse'
7
+ require 'rbconfig'
8
+ require 'socket'
9
+ require 'io/wait'
10
+
11
+ module HaveAPI
12
+ module Performance
13
+ ROOT = File.expand_path('../../..', __dir__)
14
+ FIXTURE_PATHS = %w[performance_endpoint_fixture.rb performance_sqlite_fixture.rb documentation_performance_fixture.rb].freeze
15
+
16
+ class WorkerTimeout < ContractError
17
+ attr_reader :details
18
+
19
+ def initialize(details)
20
+ @details = details
21
+ super("#{details[:kind]}: #{details[:source_sha]} #{details[:workload]} #{details[:phase]} exceeded #{details[:limit_seconds]}s")
22
+ end
23
+ end
24
+
25
+ class DocumentationScenario
26
+ # Only benchmark processes install this observer. It does not change the
27
+ # authorization result or retain a request beyond its timed call.
28
+ module AuthorizationObserver
29
+ def authorized?(...)
30
+ record = Thread.current[:haveapi_performance_documentation]
31
+ record[:authorizations] += 1 if record
32
+ super
33
+ end
34
+ end
35
+
36
+ def self.cases(profile:)
37
+ quick = %w[docs.options docs.html_resources docs.html_actions].map { |id| { id:, dimensions: { size: 8 } } }
38
+ complex = Performance.complex_cases(profile).map { |spec| JSON.parse(JSON.generate(spec), symbolize_names: true) }
39
+ return quick + complex if profile == 'quick'
40
+
41
+ quick + complex + %w[docs.options docs.html_resources docs.html_actions].product(%w[admin member]).map do |id, user|
42
+ { id:, dimensions: { size: 16, user: } }
43
+ end + %w[rust.async_example rust.async_auth rust.blocking_example rust.blocking_auth].map do |id|
44
+ { id:, dimensions: { size: 16, user: 'admin' } }
45
+ end
46
+ end
47
+
48
+ def initialize(spec)
49
+ @id = spec.fetch(:id)
50
+ dimensions = spec.fetch(:dimensions)
51
+ size = dimensions.fetch(:size, 1)
52
+ @user = dimensions.fetch(:user, 'admin')
53
+ resources, actions = @id == 'docs.html_actions' ? [1, size] : [size, 1]
54
+ @server = DocumentationPerformanceFixture.build(resource_count: dimensions.fetch(:resources, resources),
55
+ actions_per_resource: dimensions.fetch(:actions, actions),
56
+ samples_per_action: dimensions.fetch(:samples, 1),
57
+ parameters_per_action: dimensions.fetch(:parameters, 2),
58
+ rust: @id.start_with?('rust.') || @id == 'docs.html_rust')
59
+ @dimensions = dimensions
60
+ Authorization.prepend(AuthorizationObserver) unless Authorization.ancestors.include?(AuthorizationObserver)
61
+ @request = Rack::MockRequest.new(@server.app)
62
+ @headers = { 'HTTP_HOST' => 'example.test', 'HTTP_ACCEPT_LANGUAGE' => 'en',
63
+ 'HTTP_AUTHORIZATION' => "Basic #{Base64.strict_encode64("#{@user}:fixture-only")}" }
64
+ @url = 'http://example.test/benchmark/api/v1/'
65
+ prepare_rust if @id.start_with?('rust.')
66
+ end
67
+
68
+ def reset!(iterations:)
69
+ @operations = Array.new(iterations) { { queries: 0, authorizations: 0, executions: 0 } }
70
+ @index = 0
71
+ end
72
+
73
+ def call
74
+ Thread.current[:haveapi_performance_documentation] = @operations.fetch(@index)
75
+ @index += 1
76
+ if @id.start_with?('rust.')
77
+ kind = @id.end_with?('_auth') ? :auth : :example
78
+ result = @profile.adapter.render(kind:, context: @context, mode: @id.include?('blocking') ? :blocking : :async,
79
+ auth_method: :basic, auth_description: @auth)
80
+ Rack::MockResponse.new(200, { 'content-type' => 'application/json' }, JSON.generate(result))
81
+ elsif @id == 'docs.options'
82
+ @request.options(@url, **@headers, 'HTTP_ACCEPT' => 'application/json')
83
+ else
84
+ @request.get(@url, **@headers)
85
+ end
86
+ ensure
87
+ Thread.current[:haveapi_performance_documentation] = nil
88
+ end
89
+
90
+ def validate!(response, index:)
91
+ unless index.is_a?(Integer) && index >= 0 && index < @index
92
+ raise ContractError, 'Invalid or uncalled documentation operation index'
93
+ end
94
+ raise ContractError, 'Documentation response failed' unless response.status == 200
95
+
96
+ content_type = @id.start_with?('rust.') || @id == 'docs.options' ? 'application/json' : 'text/html'
97
+ raise ContractError, 'Documentation content type differs' unless response['content-type']&.start_with?(content_type)
98
+
99
+ authorizations = if @id.start_with?('rust.')
100
+ 0
101
+ elsif @id == 'docs.html_rust'
102
+ (@dimensions.fetch(:resources) * @dimensions.fetch(:actions)) + 1
103
+ else
104
+ @dimensions.fetch(:size) + 1
105
+ end
106
+ unless @operations.fetch(index) == { queries: 0, authorizations:, executions: 0 }
107
+ raise ContractError, 'Documentation operation counters differ'
108
+ end
109
+
110
+ return yield(-> { validate_body!(response) }) if block_given?
111
+
112
+ validate_body!(response)
113
+ end
114
+
115
+ def validate_body!(response)
116
+ if @id.start_with?('rust.')
117
+ value = JSON.parse(response.body)
118
+ raise ContractError, 'Rust code missing' unless value['status'] == 'code' && value['code'].is_a?(String) && !value['code'].empty?
119
+
120
+ return { http_status: 200, envelope_status: nil, body_bytes: response.body.bytesize,
121
+ body_sha256: Digest::SHA256.hexdigest(response.body), canonical_body: value }
122
+ end
123
+ if @id == 'docs.options'
124
+ identity = Identity.json(response)
125
+ raise ContractError, 'Documentation resources missing' unless identity[:canonical_body].fetch('response').fetch('resources').any?
126
+
127
+ return identity
128
+ end
129
+ unless response.body.include?('PUBLIC_SAMPLE_0') && response.body.include?('init-python-sync') && response.body.include?('init-examples')
130
+ raise ContractError, 'Documentation panels missing'
131
+ end
132
+ if response.body.include?('ADMIN_ONLY_REQUEST') != (@user == 'admin')
133
+ raise ContractError, 'Documentation authorization mismatch'
134
+ end
135
+
136
+ if @id == 'docs.html_rust'
137
+ actions = @dimensions.fetch(:resources) * @dimensions.fetch(:actions)
138
+ unless response.body.include?('init-rust-async') && response.body.include?('init-rust-blocking') &&
139
+ response.body.include?("example-resource#{@dimensions.fetch(:resources) - 1}-action3-1")
140
+ raise ContractError, 'Full Rust documentation page missing panels/actions/examples'
141
+ end
142
+
143
+ markers = %w[ADMIN_ONLY_ACTION ADMIN_ONLY_DEFAULT ADMIN_ONLY_REQUEST ADMIN_ONLY_RESPONSE ADMIN_ONLY_ACTION_SAMPLE admin_field admin_only]
144
+ unless markers.all? { |marker| response.body.include?(marker) == (@user == 'admin') }
145
+ raise ContractError, 'Full Rust documentation authorization mismatch'
146
+ end
147
+
148
+ examples = response.body.scan(/id="example-resource\d+-(?:action\d+|admin_only)-\d+-examples"/).uniq.length
149
+ expected_examples = (actions * (@dimensions.fetch(:samples) + (@user == 'admin' ? 1 : 0))) + (@user == 'admin' ? 1 : 0)
150
+ unless examples == expected_examples && response.body.include?('documentation_performance_client')
151
+ raise ContractError, 'Full Rust documentation example inventory mismatch'
152
+ end
153
+
154
+ @page = { actual_examples: examples, resources: @dimensions.fetch(:resources), actions: actions + (@user == 'admin' ? 1 : 0),
155
+ public_examples: actions * @dimensions.fetch(:samples), user: @user }
156
+ end
157
+ { http_status: 200, envelope_status: nil, body_bytes: response.body.bytesize,
158
+ body_sha256: Digest::SHA256.hexdigest(response.body), canonical_sha256: Digest::SHA256.hexdigest(response.body) }.tap do |identity|
159
+ identity[:page] = @page if @page
160
+ end
161
+ end
162
+
163
+ def counters
164
+ raise ContractError, 'Documentation operations missing' unless @index == @operations.length
165
+
166
+ { operations: @operations }
167
+ end
168
+
169
+ def close; end
170
+
171
+ private
172
+
173
+ def prepare_rust
174
+ response = @request.options(@url, **@headers, 'HTTP_ACCEPT' => 'application/json')
175
+ description = JSON.parse(response.body, symbolize_names: true).fetch(:response)
176
+ @profile = @server.documentation_client_profiles(1).fetch(:rust)
177
+ resource = description.fetch(:resources).fetch(:resource0)
178
+ action = resource.fetch(:actions).fetch(:action0)
179
+ @context = ClientExample::Context.new(version: 1, host: 'example.test', base_url: 'http://example.test/benchmark/api/',
180
+ version_url: @url, description:, profile: @profile, resource_path: ['resource0'],
181
+ resource:, action_name: 'action0', action:, sample: action.fetch(:examples).first)
182
+ @auth = description.fetch(:authentication).fetch(:basic)
183
+ end
184
+ end
185
+
186
+ class Worker
187
+ def initialize(request)
188
+ @request = request
189
+ end
190
+
191
+ def run
192
+ started = monotonic
193
+ ENV['RACK_ENV'] = 'test'
194
+ ENV['LC_ALL'] = 'C.UTF-8'
195
+ ENV['LANG'] = 'C.UTF-8'
196
+ ENV['TZ'] = 'UTC'
197
+ ENV['RUBY_YJIT_ENABLE'] = '0'
198
+ policy = RUNTIME_POLICY.merge('yjit' => (defined?(RubyVM::YJIT) && RubyVM::YJIT.enabled?) || false,
199
+ 'rjit' => (defined?(RubyVM::RJIT) && RubyVM::RJIT.enabled?) || false)
200
+ raise ContractError, 'JIT must be disabled before worker startup' unless policy == RUNTIME_POLICY
201
+
202
+ source = File.realpath(@request.fetch('source_root'))
203
+ %w[servers/ruby clients/ruby clients/rust].reverse_each { |component| $:.unshift(File.join(source, component, 'lib')) }
204
+ require 'active_record'
205
+ require 'sqlite3'
206
+ require 'haveapi'
207
+ require 'haveapi/client'
208
+ require 'rack/mock'
209
+ FIXTURE_PATHS.each { |name| require File.join(ROOT, 'servers/ruby/test_support', name) }
210
+ inventory = self.class.inventory(@request.fetch('profile'))
211
+ require 'haveapi/rust_client/documentation' if inventory.any? { |spec| spec[:id].start_with?('rust.') || spec[:id] == 'docs.html_rust' }
212
+ load_seconds = monotonic - started
213
+ order = @request.fetch('workload_order', 'forward')
214
+ raise ContractError, 'Unknown workload order' unless %w[forward reverse].include?(order)
215
+
216
+ selected = order == 'reverse' ? inventory.reverse : inventory
217
+ results = selected.map do |spec|
218
+ @current_workload = Identity.key(JSON.parse(JSON.generate(spec)))
219
+ @phase = 'build'
220
+ progress('setup')
221
+ count = @request.fetch('counts').fetch(Identity.key(JSON.parse(JSON.generate(spec))), 1)
222
+ build_started = monotonic
223
+ scenario = self.class.build(spec)
224
+ stages = { build_seconds: monotonic - build_started }
225
+ begin
226
+ warmup_count = @request.fetch('warmup_counts', {}).fetch(@current_workload, 0)
227
+ stages[:warmups] = Array.new(warmup_count == 0 ? 0 : 2) do |index|
228
+ @phase = "warmup-#{index}"
229
+ measure(scenario, warmup_count).fetch(:stages).merge(operations: warmup_count)
230
+ end
231
+ @phase = 'measured'
232
+ result = measure(scenario, count)
233
+ stages[:measured] = result.delete(:stages)
234
+ ensure
235
+ close_started = monotonic
236
+ scenario.close
237
+ stages[:close_seconds] = monotonic - close_started
238
+ end
239
+ result.merge(spec).merge(stages:)
240
+ end
241
+ inputs = if @request['benchmark'] == true
242
+ self.class.inputs(source, @request.fetch('profile'), release: @request['release_versions'] == true, benchmark: true)
243
+ elsif @request['release_versions'] == true
244
+ self.class.inputs(source, @request.fetch('profile'), release: true)
245
+ else
246
+ self.class.inputs(source, @request.fetch('profile'))
247
+ end
248
+ { schema_version: SAMPLE_SCHEMA, inventory:, cases: results, source: source_identity(source), environment: environment,
249
+ inputs:,
250
+ fixture_sha256: self.class.fixture_hashes,
251
+ runtime_policy: policy, workload_order: order, host: host_identity,
252
+ stages: { require_load_seconds: load_seconds, worker_seconds: monotonic - started } }
253
+ end
254
+
255
+ def monotonic
256
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
257
+ end
258
+
259
+ def host_identity
260
+ cpu = File.file?('/proc/cpuinfo') ? File.read('/proc/cpuinfo')[/^model name\s*:\s*(.+)$/, 1] : RbConfig::CONFIG['host_cpu']
261
+ { hostname: Socket.gethostname, os: "#{RbConfig::CONFIG['host_os']} #{Etc.uname[:release]}", cpu: cpu.to_s }
262
+ end
263
+
264
+ def self.inventory(profile)
265
+ PROFILES.fetch(profile)
266
+ PerformanceFixtures::Endpoints.cases(profile:) + PerformanceFixtures::SQLite.cases(profile:) + DocumentationScenario.cases(profile:)
267
+ end
268
+
269
+ def self.build(spec)
270
+ case spec.fetch(:id)
271
+ when /^api\./ then PerformanceFixtures::Endpoints.build(spec)
272
+ when /^sqlite\./ then PerformanceFixtures::SQLite.build(spec)
273
+ else DocumentationScenario.new(spec)
274
+ end
275
+ end
276
+
277
+ def measure(scenario, iterations)
278
+ raise ContractError, 'Invalid worker iteration count' unless iterations.is_a?(Integer) && iterations.between?(1, MAX_ITERATIONS)
279
+
280
+ reset_started = monotonic
281
+ responses = Array.new(iterations)
282
+ scenario.reset!(iterations:)
283
+ GC.start
284
+ reset_gc_seconds = monotonic - reset_started
285
+ progress('request', iterations:)
286
+ allocated = GC.stat(:total_allocated_objects)
287
+ cpu = Process.clock_gettime(Process::CLOCK_PROCESS_CPUTIME_ID)
288
+ wall = Process.clock_gettime(Process::CLOCK_MONOTONIC)
289
+ iterations.times { |index| responses[index] = scenario.call }
290
+ wall = Process.clock_gettime(Process::CLOCK_MONOTONIC) - wall
291
+ cpu = Process.clock_gettime(Process::CLOCK_PROCESS_CPUTIME_ID) - cpu
292
+ allocations = GC.stat(:total_allocated_objects) - allocated
293
+ progress('setup')
294
+ validation_started = monotonic
295
+ bodies = {}
296
+ proofs = {}
297
+ outputs = {}
298
+ responses.each_with_index do |response, index|
299
+ raw_digest = Digest::SHA256.hexdigest(response.body)
300
+ proof = proofs[raw_digest]
301
+ if proof && proof.fetch(:body) != response.body
302
+ raise ContractError, 'Raw output digest collision'
303
+ end
304
+
305
+ # The fixture still checks this operation's index/status/headers and
306
+ # counters. Only its body-only semantic validator can be reused.
307
+ validated = scenario.validate!(response, index:) do |validate_body|
308
+ proof ? proof.fetch(:validation) : validate_body.call
309
+ end
310
+ identity = validated.transform_keys(&:to_s)
311
+ unless identity.fetch('body_sha256') == raw_digest && identity.fetch('body_bytes') == response.body.bytesize &&
312
+ identity.fetch('http_status') == response.status
313
+ raise ContractError, 'Fixture output identity is incorrect'
314
+ end
315
+
316
+ if proof && validated.equal?(proof.fetch(:validation))
317
+ outputs.fetch(raw_digest)['count'] += 1
318
+ next
319
+ end
320
+
321
+ canonical = identity.delete('canonical_body')
322
+ unless canonical.nil?
323
+ canonical = Identity.canonical(canonical)
324
+ digest = Digest::SHA256.hexdigest(JSON.generate(canonical))
325
+ unless Identity.canonical(Identity.parse(response.body)) == canonical &&
326
+ (!identity.has_key?('canonical_sha256') || identity['canonical_sha256'] == digest)
327
+ raise ContractError, 'Fixture raw/canonical output differs'
328
+ end
329
+
330
+ identity['canonical_sha256'] = digest
331
+ end
332
+ raise ContractError, 'Missing canonical identity' unless identity.has_key?('canonical_sha256')
333
+
334
+ if @request['release_versions'] && scenario.is_a?(DocumentationScenario) &&
335
+ @current_workload.to_s.include?('docs.html_')
336
+ own = File.read(File.join(@request.fetch('source_root'), 'VERSION')).strip
337
+ normalized = Release.html(response.body, own)
338
+ identity['canonical_sha256'] = Digest::SHA256.hexdigest(normalized)
339
+ identity['comparison'] = { 'kind' => 'own-webui-href', 'own_version' => own }
340
+ end
341
+
342
+ if proof && (identity != proof.fetch(:output) || canonical != bodies.fetch(raw_digest).fetch(:canonical_body))
343
+ raise ContractError, 'Fixture body proof changed'
344
+ end
345
+
346
+ bodies[raw_digest] ||= { body: response.body.dup.freeze, canonical_body: Identity.snapshot(canonical) }.freeze
347
+ proofs[raw_digest] ||= { body: bodies.fetch(raw_digest).fetch(:body), validation: Identity.snapshot(validated),
348
+ output: Identity.snapshot(identity) }.freeze
349
+ outputs[raw_digest] ||= proofs.fetch(raw_digest).fetch(:output).merge('count' => 0)
350
+ outputs.fetch(raw_digest)['count'] += 1
351
+ end
352
+ records = scenario.counters.fetch(:operations)
353
+ unless records.length == iterations
354
+ raise ContractError, 'Missing per-operation counters'
355
+ end
356
+
357
+ record = records.first
358
+ unless record.keys.map(&:to_s).sort == COUNTERS.sort && record.values.all? { |value| value.is_a?(Integer) && value >= 0 } &&
359
+ records.all? { |operation| operation == record }
360
+ raise ContractError, 'Operation counters are not uniform and valid'
361
+ end
362
+
363
+ counters = { operations: iterations, record: record.dup }
364
+
365
+ { operations: iterations, wall_seconds: wall / iterations, cpu_seconds: cpu / iterations,
366
+ allocated_objects: allocations.to_f / iterations, outputs: outputs.values, bodies:, counters:,
367
+ stages: { reset_gc_seconds:, request_seconds: wall, validation_seconds: monotonic - validation_started } }
368
+ end
369
+
370
+ def self.fixture_hashes
371
+ paths = FIXTURE_PATHS.map { |name| "servers/ruby/test_support/#{name}" }
372
+ paths += %w[servers/ruby/benchmarks/endpoints.rb servers/ruby/benchmarks/performance_compare.rb]
373
+ paths.to_h { |path| [path, Digest::SHA256.file(File.join(ROOT, path)).hexdigest] }
374
+ end
375
+
376
+ # One notification at each batch boundary, outside measured allocations.
377
+ # A 30s batch watchdog also bounds every individual request in that batch.
378
+ def progress(state, iterations: nil)
379
+ return unless @request['progress_fd']
380
+
381
+ @progress ||= IO.for_fd(@request.fetch('progress_fd'), autoclose: false)
382
+ @progress.syswrite("#{JSON.generate(state:, workload: @current_workload, phase: @phase, iterations:)}\n")
383
+ end
384
+
385
+ def self.inputs(source, profile, release: false, benchmark: false)
386
+ paths = %w[Gemfile flake.lock servers/ruby/Gemfile servers/ruby/haveapi.gemspec clients/ruby/Gemfile clients/ruby/haveapi-client.gemspec]
387
+ paths += %w[clients/rust/Gemfile clients/rust/haveapi-rust-client.gemspec]
388
+ paths += %w[.ruby-version flake.nix] if benchmark
389
+ raw_manifests = paths.to_h do |path|
390
+ target = File.join(source, path)
391
+ raise ContractError, "Missing dependency input #{target}" unless File.file?(target)
392
+
393
+ [path, File.read(target)]
394
+ end
395
+ preparation = Release.json_preparation(raw_manifests)
396
+ manifests = raw_manifests.merge(preparation.fetch('normalized_contents')).transform_values { |text| Digest::SHA256.hexdigest(text) }
397
+ lock_paths = benchmark ? ['servers/ruby/benchmarks/Gemfile.lock'] : %w[Gemfile.lock servers/ruby/Gemfile.lock clients/rust/Gemfile.lock]
398
+ locks = lock_paths.to_h do |path|
399
+ target = File.join(ROOT, path)
400
+ raise ContractError, "Missing resolved lock #{target}" unless File.file?(target)
401
+
402
+ [path, Digest::SHA256.file(target).hexdigest]
403
+ end
404
+ if release
405
+ contents = Release.paths(profile).to_h { |path| [path, File.read(File.join(source, path))] }
406
+ receipt = Release.capture(contents)
407
+ versions = [HaveAPI::VERSION, HaveAPI::Client::VERSION]
408
+ versions << HaveAPI::RustClient::VERSION
409
+ raise ContractError, 'Wrong loaded local package version' unless versions.all? { |version| version == receipt.fetch('own_version') }
410
+
411
+ result = { manifests: receipt.fetch('normalized_sha256'), locks:, release_versions: receipt }
412
+ else
413
+ result = { manifests:, locks:, json_preparation: preparation }
414
+ end
415
+ if benchmark
416
+ result[:manifests] = result.fetch(:manifests).except('flake.nix', 'flake.lock')
417
+ result[:contributor_flakes] = raw_manifests.slice('flake.nix', 'flake.lock').transform_values { |content| { 'content' => content } }
418
+ result[:benchmark] = BENCHMARK_INPUTS.to_h do |path|
419
+ content = File.read(File.join(ROOT, path))
420
+ [path, { 'content' => content, 'sha256' => Digest::SHA256.hexdigest(content) }]
421
+ end
422
+ end
423
+ result
424
+ end
425
+
426
+ def environment
427
+ { ruby: { engine: RUBY_ENGINE, version: RUBY_VERSION, platform: RUBY_PLATFORM,
428
+ abi: RbConfig::CONFIG.fetch('ruby_version'), executable: RbConfig.ruby },
429
+ gems: Gem.loaded_specs.except('haveapi', 'haveapi-client', 'haveapi-rust-client')
430
+ .sort.to_h.transform_values { |spec| { version: spec.version.to_s, platform: spec.platform.to_s } } }
431
+ end
432
+
433
+ def source_identity(source)
434
+ methods = { 'servers/ruby/lib/haveapi/server.rb' => Server.instance_method(:mount),
435
+ 'servers/ruby/lib/haveapi/action.rb' => Action.instance_method(:exec),
436
+ 'servers/ruby/lib/haveapi/model_adapters/active_record.rb' => ModelAdapters::ActiveRecord.method(:handle?),
437
+ 'clients/ruby/lib/haveapi/client/client.rb' => Client::Client.instance_method(:initialize) }
438
+ if HaveAPI.const_defined?(:RustClient)
439
+ methods['clients/rust/lib/haveapi/rust_client/documentation.rb'] = RustClient::Documentation.instance_method(:render)
440
+ end
441
+ methods.each do |path, method|
442
+ raise ContractError, "Wrong loaded source for #{path}" unless File.identical?(method.source_location.first, File.join(source, path))
443
+ end
444
+ loaded = $".grep(%r{/(?:servers/ruby|clients/ruby|clients/rust)/lib/}).to_h do |path|
445
+ real = File.realpath(path)
446
+ raise ContractError, "Runtime escaped selected source: #{real}" unless real.start_with?("#{source}/")
447
+
448
+ [real.delete_prefix("#{source}/"), Digest::SHA256.file(real).hexdigest]
449
+ end
450
+ { root: source, sha: @request.fetch('source_sha'), loaded_sources: loaded }
451
+ end
452
+ end
453
+
454
+ class CLI
455
+ EXIT_CODES = { 'pass' => 0, 'regression' => 1, 'inconclusive' => 0, 'error' => 3 }.freeze
456
+
457
+ def initialize(argv)
458
+ @argv = argv.dup
459
+ @mode = @argv.shift
460
+ @options = { profile: 'quick' }
461
+ end
462
+
463
+ def execute
464
+ if @argv == ['--help'] || @mode == '--help'
465
+ puts 'endpoints.rb run --baseline-root PATH --baseline-sha SHA --candidate-root PATH --candidate-sha SHA --profile quick|weekly --budgets FILE --output NEWDIR'
466
+ puts 'endpoints.rb compare --input samples.json --budgets FILE --output NEWDIR'
467
+ return 0
468
+ end
469
+ return worker if @mode == 'worker'
470
+
471
+ comparison = { 'policy' => Comparator::POLICY, 'comparator_sha256' => Comparator::SOURCE_SHA256 }
472
+ parse
473
+ destination = File.expand_path(@options.fetch(:output))
474
+ raise ContractError, 'Output directory must not exist' if File.exist?(destination)
475
+
476
+ @output = destination
477
+ FileUtils.mkdir_p(@output)
478
+ budget_path = @options.fetch(:budgets)
479
+ budgets = Identity.parse(File.read(budget_path))
480
+ digest = Digest::SHA256.file(budget_path).hexdigest
481
+ if @mode == 'run'
482
+ samples = run_samples(digest)
483
+ sample_bytes = JSON.pretty_generate(samples)
484
+ File.binwrite(File.join(@output, 'samples.json'), sample_bytes)
485
+ else
486
+ sample_bytes = File.binread(@options.fetch(:input))
487
+ end
488
+ comparison['samples_sha256'] = Digest::SHA256.hexdigest(sample_bytes)
489
+ samples = Identity.parse(sample_bytes) unless @mode == 'run'
490
+ report = Comparator.new(samples, budgets, budget_sha256: digest).compare.merge('comparison' => comparison)
491
+ File.write(File.join(@output, 'report.json'), JSON.pretty_generate(report))
492
+ puts JSON.generate(status: report.fetch('status'), report: File.join(@output, 'report.json'))
493
+ EXIT_CODES.fetch(report.fetch('status'))
494
+ rescue StandardError, LoadError => e
495
+ report = { schema_version: SAMPLE_SCHEMA, status: 'error', error: { class: e.class.name, message: e.message } }
496
+ report[:error][:details] = e.details if e.is_a?(WorkerTimeout)
497
+ %i[baseline_sha candidate_sha profile].each { |key| report[key] = @options[key] if @options.has_key?(key) }
498
+ if samples.is_a?(Hash)
499
+ report.merge!(samples.slice('baseline_sha', 'candidate_sha', 'profile', 'harness_sha256', 'budget_sha256').transform_keys(&:to_sym))
500
+ end
501
+ report[:comparison] = comparison if comparison
502
+ File.write(File.join(@output, 'report.json'), JSON.pretty_generate(report)) if @output && File.directory?(@output)
503
+ warn JSON.generate(report)
504
+ 3
505
+ end
506
+
507
+ private
508
+
509
+ def parse
510
+ raise ContractError, 'Expected run or compare' unless %w[run compare].include?(@mode)
511
+
512
+ OptionParser.new do |parser|
513
+ %w[baseline-root baseline-sha candidate-root candidate-sha profile budgets output input push-event].each do |name|
514
+ parser.on("--#{name} VALUE") { |value| @options[name.tr('-', '_').to_sym] = value }
515
+ end
516
+ end.parse!(@argv)
517
+ raise ContractError, 'Unexpected arguments' unless @argv.empty?
518
+
519
+ PROFILES.fetch(@options.fetch(:profile))
520
+ required = @mode == 'run' ? %i[baseline_root baseline_sha candidate_root candidate_sha budgets output] : %i[input budgets output]
521
+ required.each { |key| @options.fetch(key) }
522
+ end
523
+
524
+ def worker
525
+ raise ContractError, 'Unexpected worker arguments' unless @argv.empty?
526
+
527
+ result = Worker.new(Identity.parse($stdin.read)).run
528
+ serialization_started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
529
+ serialized = JSON.generate(result)
530
+ serialization_seconds = Process.clock_gettime(Process::CLOCK_MONOTONIC) - serialization_started
531
+ # Add the serialization receipt without serializing the response trees a
532
+ # second time solely to report the first serialization's duration.
533
+ puts "#{serialized.delete_suffix('}')},\"serialization_seconds\":#{serialization_seconds}}"
534
+ 0
535
+ end
536
+
537
+ def git(*args)
538
+ output, status = Open3.capture2('git', '-C', ROOT, *args, err: File::NULL)
539
+ raise ContractError, "Cannot verify source object: git #{args.first}" unless status.success?
540
+
541
+ output
542
+ end
543
+
544
+ def ancestor?(left, right)
545
+ _output, status = Open3.capture2('git', '-C', ROOT, 'merge-base', '--is-ancestor', left, right, err: File::NULL)
546
+ raise ContractError, 'Cannot establish source ancestry' unless [0, 1].include?(status.exitstatus)
547
+
548
+ status.success?
549
+ end
550
+
551
+ def verify_source(role)
552
+ sha = @options.fetch(:"#{role}_sha")
553
+ raise ContractError, 'Source SHA must be full immutable SHA' unless sha.match?(/\A[0-9a-f]{40}\z/) && sha != '0' * 40
554
+
555
+ root = File.realpath(@options.fetch(:"#{role}_root"))
556
+ git('cat-file', '-e', "#{sha}^{commit}")
557
+ files = git('ls-tree', '-rz', '--full-tree', sha).split("\0")
558
+ graph = {}
559
+ files.each do |entry|
560
+ metadata, path = entry.split("\t", 2)
561
+ mode, kind, expected = metadata.split
562
+ raise ContractError, "Unsupported source entry #{path}" unless kind == 'blob'
563
+
564
+ target = File.join(root, path)
565
+ contents = mode == '120000' ? File.readlink(target) : File.binread(target)
566
+ actual = Digest::SHA1.hexdigest("blob #{contents.bytesize}\0#{contents}")
567
+ raise ContractError, "Source tree mismatch #{role}:#{path}" unless actual == expected
568
+
569
+ graph[path] = Digest::SHA256.hexdigest(contents)
570
+ end
571
+ { root:, sha:, tracked_files: files.length, files_sha256: graph, tree: git('rev-parse', "#{sha}^{tree}").strip }
572
+ end
573
+
574
+ def verify_runtime
575
+ expected = Identity.parse(File.read(@options.fetch(:budgets))).fetch('environment').fetch('weekly')
576
+ ruby = JSON.parse(JSON.generate(Worker.new({}).environment.fetch(:ruby)))
577
+ raise ContractError, 'Calibration Ruby mismatch' unless ruby == expected.fetch('ruby')
578
+
579
+ expected.fetch('gems').each do |name, identity|
580
+ unless Gem::Specification.find_all_by_name(name, identity.fetch('version')).any? { |spec| spec.platform.to_s == identity.fetch('platform') }
581
+ raise ContractError, "Missing calibrated dependency #{name}"
582
+ end
583
+ end
584
+ end
585
+
586
+ def run_samples(digest)
587
+ policy = PROFILES.fetch(@options.fetch(:profile))
588
+ verify_runtime
589
+ Performance.validate_source_pair(profile: @options[:profile], baseline_sha: @options[:baseline_sha],
590
+ candidate_sha: @options[:candidate_sha])
591
+
592
+ sources = %w[baseline candidate].to_h { |role| [role, verify_source(role)] }
593
+ @sources = JSON.parse(JSON.generate(sources))
594
+ @release_lineage = Release.lineage(git: method(:git), ancestor: method(:ancestor?), candidate: @options[:candidate_sha])
595
+ if @options[:push_event]
596
+ raise ContractError, 'Push event exception requires quick profile' unless @options[:profile] == 'quick'
597
+
598
+ push = Performance.push_pair!(Identity.parse(File.read(@options[:push_event])), baseline: @options[:baseline_sha], candidate: @options[:candidate_sha])
599
+ raise ContractError, 'Missing exact push pair' unless push
600
+ end
601
+ unless push || ancestor?(@options[:baseline_sha], @options[:candidate_sha]) ||
602
+ (@options[:profile] == 'weekly' && @options[:baseline_sha] == REFERENCE && @release_lineage)
603
+ raise ContractError, 'Baseline must be an ancestor of candidate'
604
+ end
605
+
606
+ @sources.each_value do |source|
607
+ Release::VERSIONS.each do |path, name|
608
+ Performance.gemspec(source.fetch('root'), "#{path.split('/lib/').first}/#{name}.gemspec")
609
+ end
610
+ end
611
+
612
+ pilot = child('baseline', {}, 'pilot')
613
+ inventory = pilot.fetch('inventory')
614
+ counts = pilot.fetch('cases').to_h do |item|
615
+ count = (policy.fetch(:target) / item.fetch('wall_seconds')).ceil.clamp(1, MAX_ITERATIONS)
616
+ [Identity.key(item), count]
617
+ end
618
+ warmup_counts = pilot.fetch('cases').to_h do |item|
619
+ count = item.fetch('wall_seconds') >= policy.fetch(:target) ? 0 : (0.100 / item.fetch('wall_seconds')).ceil.clamp(1, counts.fetch(Identity.key(item)))
620
+ [Identity.key(item), count]
621
+ end
622
+ @warmup_counts = warmup_counts
623
+ calibration = child('baseline', counts, 'calibration')
624
+ adjusted = false
625
+ calibration.fetch('cases').each do |item|
626
+ key = Identity.key(item)
627
+ duration = item.fetch('wall_seconds') * counts.fetch(key)
628
+ next if warmup_counts.fetch(key) == 0 || duration >= policy.fetch(:adequate) || counts.fetch(key) == MAX_ITERATIONS
629
+
630
+ counts[key] = (counts.fetch(key) * policy.fetch(:target) / duration).ceil.clamp(1, MAX_ITERATIONS)
631
+ adjusted = true
632
+ end
633
+ calibration = child('baseline', counts, 'calibration-adjusted') if adjusted
634
+ cases = inventory.map do |spec|
635
+ observed = calibration.fetch('cases').find { |item| Identity.key(item) == Identity.key(spec) }
636
+ count = counts.fetch(Identity.key(spec))
637
+ duration = observed.fetch('wall_seconds') * count
638
+ spec.merge('iterations' => count, 'pilot' => observed.merge('selected_iterations' => count,
639
+ 'warmup_iterations' => warmup_counts.fetch(Identity.key(spec)), 'target_seconds' => policy.fetch(:target),
640
+ 'baseline_batch_seconds' => duration, 'adequate_signal' => duration >= policy.fetch(:adequate),
641
+ 'capped' => count == MAX_ITERATIONS, 'calibration_adjusted' => adjusted), 'self_pairs' => [], 'pairs' => [])
642
+ end
643
+ policy[:self_pairs].times do |index|
644
+ workload_order = index.even? ? 'forward' : 'reverse'
645
+ left = child('baseline', counts, "aa-#{index}-a", workload_order)
646
+ right = child('baseline', counts, "aa-#{index}-b", workload_order)
647
+ append_pair(cases, 'self_pairs', %w[baseline baseline], left, right, workload_order)
648
+ end
649
+ policy[:pairs].times do |index|
650
+ order = index.even? ? %w[baseline candidate] : %w[candidate baseline]
651
+ workload_order = index.even? ? 'forward' : 'reverse'
652
+ observations = order.map { |role| child(role, counts, "ab-#{index}-#{role}", workload_order) }
653
+ baseline = observations.fetch(order.index('baseline'))
654
+ candidate = observations.fetch(order.index('candidate'))
655
+ append_pair(cases, 'pairs', order, baseline, candidate, workload_order)
656
+ end
657
+ { 'schema_version' => SAMPLE_SCHEMA, 'profile' => @options[:profile], 'baseline_sha' => @options[:baseline_sha],
658
+ 'candidate_sha' => @options[:candidate_sha], 'harness_sha256' => Identity.digest(Worker.fixture_hashes),
659
+ 'fixture_sha256' => Worker.fixture_hashes, 'budget_sha256' => digest, 'sources' => @sources,
660
+ 'environment' => @common.fetch('environment'), 'inputs' => @common.fetch('inputs'), 'release_lineage' => @release_lineage,
661
+ 'observations' => @observations,
662
+ 'inventory' => inventory, 'cases' => cases }
663
+ end
664
+
665
+ def append_pair(cases, name, order, left, right, workload_order)
666
+ cases.each do |item|
667
+ key = Identity.key(item)
668
+ a = left.fetch('cases').find { |entry| Identity.key(entry) == key }
669
+ b = right.fetch('cases').find { |entry| Identity.key(entry) == key }
670
+ raise ContractError, 'Worker omitted a case' unless a && b
671
+
672
+ item[name] << { 'order' => order, 'workload_order' => workload_order, 'baseline' => a, 'candidate' => b }
673
+ end
674
+ end
675
+
676
+ def child(role, counts, label, workload_order = 'forward')
677
+ request = { source_root: File.realpath(@options.fetch(:"#{role}_root")), source_sha: @options.fetch(:"#{role}_sha"),
678
+ profile: @options[:profile], workload_order:, counts:, warmup_counts: @warmup_counts || {}, release_versions: !@release_lineage.nil?, benchmark: true }
679
+ env = ENV.keys.grep(/\ABUNDLE|\ABUNDLER|\ARUBY(?:OPT|LIB|GEMS_GEMDEPS)\z/).to_h { |key| [key, nil] }
680
+ env.merge!('LC_ALL' => 'C.UTF-8', 'LANG' => 'C.UTF-8', 'TZ' => 'UTC', 'RUBY_YJIT_ENABLE' => '0', 'RACK_ENV' => 'test')
681
+ process_started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
682
+ output, errors, status = capture_worker(env, request, label)
683
+ process_wall_seconds = Process.clock_gettime(Process::CLOCK_MONOTONIC) - process_started
684
+ File.write(File.join(@output, "#{label}.stderr.log"), errors)
685
+ File.write(File.join(@output, "#{label}.json"), output)
686
+ raise ContractError, "Worker failed #{label} (#{status.exitstatus}): #{errors.lines.last}" unless status.success?
687
+
688
+ result = JSON.parse(output)
689
+ raise ContractError, 'Unsupported worker sample schema' unless result.fetch('schema_version').eql?(SAMPLE_SCHEMA)
690
+ raise ContractError, 'Worker source identity mismatch' unless result.fetch('source').fetch('root') == request[:source_root] && result['source']['sha'] == request[:source_sha]
691
+ raise ContractError, 'Worker fixture graph mismatch' unless result.fetch('fixture_sha256') == Worker.fixture_hashes
692
+
693
+ result.fetch('source').fetch('loaded_sources').each do |path, sha256|
694
+ unless @sources.fetch(role).fetch('files_sha256').fetch(path) == sha256
695
+ raise ContractError, 'Loaded runtime differs from exact source object'
696
+ end
697
+ end
698
+
699
+ inputs = result.fetch('inputs')
700
+ if @release_lineage
701
+ Release.validate_inputs(inputs, @sources.fetch(role), @options.fetch(:profile))
702
+ elsif inputs.has_key?('release_versions')
703
+ raise ContractError, 'Unproven own-version normalization'
704
+ else
705
+ Release.validate_json_preparation(inputs, @sources.fetch(role))
706
+ end
707
+ expected = Identity.parse(File.read(@options.fetch(:budgets))).fetch('environment').fetch('weekly')
708
+ raise ContractError, 'Calibration environment mismatch' unless result.fetch('environment') == expected
709
+
710
+ common = result.slice('environment', 'inventory', 'fixture_sha256').merge('inputs' => Release.graph(inputs))
711
+ @common ||= common
712
+ raise ContractError, 'Incompatible dependency/runtime/input/workload graph' unless common == @common
713
+
714
+ @observations ||= {}
715
+ @observations[label] = result.slice('source', 'environment', 'inputs', 'fixture_sha256', 'runtime_policy', 'host',
716
+ 'workload_order', 'stages', 'serialization_seconds').merge('process_wall_seconds' => process_wall_seconds)
717
+ result.fetch('cases').each { |item| item['observation'] = label }
718
+ result
719
+ end
720
+
721
+ def worker_command
722
+ [RbConfig.ruby, '--disable-yjit', __FILE__, 'worker']
723
+ end
724
+
725
+ def capture_worker(env, request, label)
726
+ reader, writer = IO.pipe
727
+ request = request.merge(progress_fd: writer.fileno)
728
+ output = errors = ''
729
+ status = nil
730
+ Open3.popen3(env, *worker_command, writer.fileno => writer, pgroup: true) do |input, stdout, stderr, waiter|
731
+ writer.close
732
+ out_thread = Thread.new { stdout.read }
733
+ err_thread = Thread.new { stderr.read }
734
+ begin
735
+ input.write(JSON.generate(request))
736
+ input.close
737
+ event = { 'state' => 'setup' }
738
+ File.open(File.join(@output, "#{label}.progress.jsonl"), 'w') do |log|
739
+ loop do
740
+ limit = event.fetch('state') == 'request' ? REQUEST_LIMIT_SECONDS : SETUP_LIMIT_SECONDS
741
+ unless reader.wait_readable(limit)
742
+ raise WorkerTimeout.new(kind: event['state'] == 'request' ? 'request_batch_timeout' : 'worker_setup_timeout',
743
+ source_sha: request.fetch(:source_sha), workload: event['workload'],
744
+ phase: "#{label}/#{event['phase']}", limit_seconds: limit)
745
+ end
746
+ line = reader.gets
747
+ break unless line
748
+
749
+ log.write(line)
750
+ log.flush
751
+ event = JSON.parse(line)
752
+ raise ContractError, 'Invalid worker progress' unless %w[request setup].include?(event.fetch('state'))
753
+ end
754
+ end
755
+ unless waiter.join(SETUP_LIMIT_SECONDS)
756
+ raise WorkerTimeout.new(kind: 'worker_setup_timeout', source_sha: request.fetch(:source_sha),
757
+ workload: event['workload'], phase: "#{label}/serialization", limit_seconds: SETUP_LIMIT_SECONDS)
758
+ end
759
+ status = waiter.value
760
+ ensure
761
+ unless waiter.join(0)
762
+ begin
763
+ Process.kill('TERM', -waiter.pid)
764
+ waiter.join(0.2)
765
+ Process.kill('KILL', -waiter.pid)
766
+ rescue Errno::ESRCH
767
+ # The owned process group has exited.
768
+ end
769
+ waiter.value
770
+ end
771
+ output = out_thread.value
772
+ errors = err_thread.value
773
+ File.write(File.join(@output, "#{label}.stderr.log"), errors)
774
+ File.write(File.join(@output, "#{label}.json"), output)
775
+ end
776
+ end
777
+ [output, errors, status]
778
+ ensure
779
+ reader&.close unless reader&.closed?
780
+ writer&.close unless writer&.closed?
781
+ end
782
+ end
783
+ end
784
+ end
785
+
786
+ exit HaveAPI::Performance::CLI.new(ARGV).execute if $0 == __FILE__