haveapi 0.30.1 → 0.30.2

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.
@@ -0,0 +1,730 @@
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
+ { schema_version: SAMPLE_SCHEMA, inventory:, cases: results, source: source_identity(source), environment: environment,
242
+ inputs: @request['release_versions'] ? self.class.inputs(source, @request.fetch('profile'), release: true) : self.class.inputs(source, @request.fetch('profile')),
243
+ fixture_sha256: self.class.fixture_hashes,
244
+ runtime_policy: policy, workload_order: order, host: host_identity,
245
+ stages: { require_load_seconds: load_seconds, worker_seconds: monotonic - started } }
246
+ end
247
+
248
+ def monotonic
249
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
250
+ end
251
+
252
+ def host_identity
253
+ cpu = File.file?('/proc/cpuinfo') ? File.read('/proc/cpuinfo')[/^model name\s*:\s*(.+)$/, 1] : RbConfig::CONFIG['host_cpu']
254
+ { hostname: Socket.gethostname, os: "#{RbConfig::CONFIG['host_os']} #{Etc.uname[:release]}", cpu: cpu.to_s }
255
+ end
256
+
257
+ def self.inventory(profile)
258
+ PROFILES.fetch(profile)
259
+ PerformanceFixtures::Endpoints.cases(profile:) + PerformanceFixtures::SQLite.cases(profile:) + DocumentationScenario.cases(profile:)
260
+ end
261
+
262
+ def self.build(spec)
263
+ case spec.fetch(:id)
264
+ when /^api\./ then PerformanceFixtures::Endpoints.build(spec)
265
+ when /^sqlite\./ then PerformanceFixtures::SQLite.build(spec)
266
+ else DocumentationScenario.new(spec)
267
+ end
268
+ end
269
+
270
+ def measure(scenario, iterations)
271
+ raise ContractError, 'Invalid worker iteration count' unless iterations.is_a?(Integer) && iterations.between?(1, MAX_ITERATIONS)
272
+
273
+ reset_started = monotonic
274
+ responses = Array.new(iterations)
275
+ scenario.reset!(iterations:)
276
+ GC.start
277
+ reset_gc_seconds = monotonic - reset_started
278
+ progress('request', iterations:)
279
+ allocated = GC.stat(:total_allocated_objects)
280
+ cpu = Process.clock_gettime(Process::CLOCK_PROCESS_CPUTIME_ID)
281
+ wall = Process.clock_gettime(Process::CLOCK_MONOTONIC)
282
+ iterations.times { |index| responses[index] = scenario.call }
283
+ wall = Process.clock_gettime(Process::CLOCK_MONOTONIC) - wall
284
+ cpu = Process.clock_gettime(Process::CLOCK_PROCESS_CPUTIME_ID) - cpu
285
+ allocations = GC.stat(:total_allocated_objects) - allocated
286
+ progress('setup')
287
+ validation_started = monotonic
288
+ bodies = {}
289
+ proofs = {}
290
+ outputs = {}
291
+ responses.each_with_index do |response, index|
292
+ raw_digest = Digest::SHA256.hexdigest(response.body)
293
+ proof = proofs[raw_digest]
294
+ if proof && proof.fetch(:body) != response.body
295
+ raise ContractError, 'Raw output digest collision'
296
+ end
297
+
298
+ # The fixture still checks this operation's index/status/headers and
299
+ # counters. Only its body-only semantic validator can be reused.
300
+ validated = scenario.validate!(response, index:) do |validate_body|
301
+ proof ? proof.fetch(:validation) : validate_body.call
302
+ end
303
+ identity = validated.transform_keys(&:to_s)
304
+ unless identity.fetch('body_sha256') == raw_digest && identity.fetch('body_bytes') == response.body.bytesize &&
305
+ identity.fetch('http_status') == response.status
306
+ raise ContractError, 'Fixture output identity is incorrect'
307
+ end
308
+
309
+ if proof && validated.equal?(proof.fetch(:validation))
310
+ outputs.fetch(raw_digest)['count'] += 1
311
+ next
312
+ end
313
+
314
+ canonical = identity.delete('canonical_body')
315
+ unless canonical.nil?
316
+ canonical = Identity.canonical(canonical)
317
+ digest = Digest::SHA256.hexdigest(JSON.generate(canonical))
318
+ unless Identity.canonical(Identity.parse(response.body)) == canonical &&
319
+ (!identity.has_key?('canonical_sha256') || identity['canonical_sha256'] == digest)
320
+ raise ContractError, 'Fixture raw/canonical output differs'
321
+ end
322
+
323
+ identity['canonical_sha256'] = digest
324
+ end
325
+ raise ContractError, 'Missing canonical identity' unless identity.has_key?('canonical_sha256')
326
+
327
+ if @request['release_versions'] && scenario.is_a?(DocumentationScenario) &&
328
+ @current_workload.to_s.include?('docs.html_')
329
+ own = File.read(File.join(@request.fetch('source_root'), 'VERSION')).strip
330
+ normalized = Release.html(response.body, own)
331
+ identity['canonical_sha256'] = Digest::SHA256.hexdigest(normalized)
332
+ identity['comparison'] = { 'kind' => 'own-webui-href', 'own_version' => own }
333
+ end
334
+
335
+ if proof && (identity != proof.fetch(:output) || canonical != bodies.fetch(raw_digest).fetch(:canonical_body))
336
+ raise ContractError, 'Fixture body proof changed'
337
+ end
338
+
339
+ bodies[raw_digest] ||= { body: response.body.dup.freeze, canonical_body: Identity.snapshot(canonical) }.freeze
340
+ proofs[raw_digest] ||= { body: bodies.fetch(raw_digest).fetch(:body), validation: Identity.snapshot(validated),
341
+ output: Identity.snapshot(identity) }.freeze
342
+ outputs[raw_digest] ||= proofs.fetch(raw_digest).fetch(:output).merge('count' => 0)
343
+ outputs.fetch(raw_digest)['count'] += 1
344
+ end
345
+ records = scenario.counters.fetch(:operations)
346
+ unless records.length == iterations
347
+ raise ContractError, 'Missing per-operation counters'
348
+ end
349
+
350
+ record = records.first
351
+ unless record.keys.map(&:to_s).sort == COUNTERS.sort && record.values.all? { |value| value.is_a?(Integer) && value >= 0 } &&
352
+ records.all? { |operation| operation == record }
353
+ raise ContractError, 'Operation counters are not uniform and valid'
354
+ end
355
+
356
+ counters = { operations: iterations, record: record.dup }
357
+
358
+ { operations: iterations, wall_seconds: wall / iterations, cpu_seconds: cpu / iterations,
359
+ allocated_objects: allocations.to_f / iterations, outputs: outputs.values, bodies:, counters:,
360
+ stages: { reset_gc_seconds:, request_seconds: wall, validation_seconds: monotonic - validation_started } }
361
+ end
362
+
363
+ def self.fixture_hashes
364
+ paths = FIXTURE_PATHS.map { |name| "servers/ruby/test_support/#{name}" }
365
+ paths += %w[servers/ruby/benchmarks/endpoints.rb servers/ruby/benchmarks/performance_compare.rb]
366
+ paths.to_h { |path| [path, Digest::SHA256.file(File.join(ROOT, path)).hexdigest] }
367
+ end
368
+
369
+ # One notification at each batch boundary, outside measured allocations.
370
+ # A 30s batch watchdog also bounds every individual request in that batch.
371
+ def progress(state, iterations: nil)
372
+ return unless @request['progress_fd']
373
+
374
+ @progress ||= IO.for_fd(@request.fetch('progress_fd'), autoclose: false)
375
+ @progress.syswrite("#{JSON.generate(state:, workload: @current_workload, phase: @phase, iterations:)}\n")
376
+ end
377
+
378
+ def self.inputs(source, profile, release: false)
379
+ paths = %w[Gemfile flake.lock servers/ruby/Gemfile servers/ruby/haveapi.gemspec clients/ruby/Gemfile clients/ruby/haveapi-client.gemspec]
380
+ paths += %w[clients/rust/Gemfile clients/rust/haveapi-rust-client.gemspec]
381
+ raw_manifests = paths.to_h do |path|
382
+ target = File.join(source, path)
383
+ raise ContractError, "Missing dependency input #{target}" unless File.file?(target)
384
+
385
+ [path, File.read(target)]
386
+ end
387
+ preparation = Release.json_preparation(raw_manifests)
388
+ manifests = raw_manifests.merge(preparation.fetch('normalized_contents')).transform_values { |text| Digest::SHA256.hexdigest(text) }
389
+ lock_paths = %w[Gemfile.lock servers/ruby/Gemfile.lock]
390
+ lock_paths << 'clients/rust/Gemfile.lock'
391
+ locks = lock_paths.to_h do |path|
392
+ target = File.join(ROOT, path)
393
+ raise ContractError, "Missing resolved lock #{target}" unless File.file?(target)
394
+
395
+ [path, Digest::SHA256.file(target).hexdigest]
396
+ end
397
+ if release
398
+ contents = Release.paths(profile).to_h { |path| [path, File.read(File.join(source, path))] }
399
+ receipt = Release.capture(contents)
400
+ versions = [HaveAPI::VERSION, HaveAPI::Client::VERSION]
401
+ versions << HaveAPI::RustClient::VERSION
402
+ raise ContractError, 'Wrong loaded local package version' unless versions.all? { |version| version == receipt.fetch('own_version') }
403
+
404
+ return { manifests: receipt.fetch('normalized_sha256'), locks:, release_versions: receipt }
405
+ end
406
+ { manifests:, locks:, json_preparation: preparation }
407
+ end
408
+
409
+ def environment
410
+ { ruby: { engine: RUBY_ENGINE, version: RUBY_VERSION, platform: RUBY_PLATFORM,
411
+ abi: RbConfig::CONFIG.fetch('ruby_version'), executable: RbConfig.ruby },
412
+ gems: Gem.loaded_specs.except('haveapi', 'haveapi-client', 'haveapi-rust-client')
413
+ .sort.to_h.transform_values { |spec| { version: spec.version.to_s, platform: spec.platform.to_s } } }
414
+ end
415
+
416
+ def source_identity(source)
417
+ methods = { 'servers/ruby/lib/haveapi/server.rb' => Server.instance_method(:mount),
418
+ 'servers/ruby/lib/haveapi/action.rb' => Action.instance_method(:exec),
419
+ 'servers/ruby/lib/haveapi/model_adapters/active_record.rb' => ModelAdapters::ActiveRecord.method(:handle?),
420
+ 'clients/ruby/lib/haveapi/client/client.rb' => Client::Client.instance_method(:initialize) }
421
+ if HaveAPI.const_defined?(:RustClient)
422
+ methods['clients/rust/lib/haveapi/rust_client/documentation.rb'] = RustClient::Documentation.instance_method(:render)
423
+ end
424
+ methods.each do |path, method|
425
+ raise ContractError, "Wrong loaded source for #{path}" unless File.identical?(method.source_location.first, File.join(source, path))
426
+ end
427
+ loaded = $".grep(%r{/(?:servers/ruby|clients/ruby|clients/rust)/lib/}).to_h do |path|
428
+ real = File.realpath(path)
429
+ raise ContractError, "Runtime escaped selected source: #{real}" unless real.start_with?("#{source}/")
430
+
431
+ [real.delete_prefix("#{source}/"), Digest::SHA256.file(real).hexdigest]
432
+ end
433
+ { root: source, sha: @request.fetch('source_sha'), loaded_sources: loaded }
434
+ end
435
+ end
436
+
437
+ class CLI
438
+ EXIT_CODES = { 'pass' => 0, 'regression' => 1, 'inconclusive' => 0, 'error' => 3 }.freeze
439
+
440
+ def initialize(argv)
441
+ @argv = argv.dup
442
+ @mode = @argv.shift
443
+ @options = { profile: 'quick' }
444
+ end
445
+
446
+ def execute
447
+ if @argv == ['--help'] || @mode == '--help'
448
+ puts 'endpoints.rb run --baseline-root PATH --baseline-sha SHA --candidate-root PATH --candidate-sha SHA --profile quick|weekly --budgets FILE --output NEWDIR'
449
+ puts 'endpoints.rb compare --input samples.json --budgets FILE --output NEWDIR'
450
+ return 0
451
+ end
452
+ return worker if @mode == 'worker'
453
+
454
+ parse
455
+ destination = File.expand_path(@options.fetch(:output))
456
+ raise ContractError, 'Output directory must not exist' if File.exist?(destination)
457
+
458
+ @output = destination
459
+ FileUtils.mkdir_p(@output)
460
+ budget_path = @options.fetch(:budgets)
461
+ budgets = Identity.parse(File.read(budget_path))
462
+ digest = Digest::SHA256.file(budget_path).hexdigest
463
+ samples = @mode == 'run' ? run_samples(digest) : Identity.parse(File.read(@options.fetch(:input)))
464
+ File.write(File.join(@output, 'samples.json'), JSON.pretty_generate(samples)) if @mode == 'run'
465
+ report = Comparator.new(samples, budgets, budget_sha256: digest).compare
466
+ File.write(File.join(@output, 'report.json'), JSON.pretty_generate(report))
467
+ puts JSON.generate(status: report.fetch('status'), report: File.join(@output, 'report.json'))
468
+ EXIT_CODES.fetch(report.fetch('status'))
469
+ rescue StandardError, LoadError => e
470
+ report = { schema_version: SAMPLE_SCHEMA, status: 'error', error: { class: e.class.name, message: e.message } }
471
+ report[:error][:details] = e.details if e.is_a?(WorkerTimeout)
472
+ %i[baseline_sha candidate_sha profile].each { |key| report[key] = @options[key] if @options.has_key?(key) }
473
+ report[:harness_sha256] = Identity.digest(Worker.fixture_hashes) if FIXTURE_PATHS.all? { |name| File.file?(File.join(ROOT, 'servers/ruby/test_support', name)) }
474
+ File.write(File.join(@output, 'report.json'), JSON.pretty_generate(report)) if @output && File.directory?(@output)
475
+ warn JSON.generate(report)
476
+ 3
477
+ end
478
+
479
+ private
480
+
481
+ def parse
482
+ raise ContractError, 'Expected run or compare' unless %w[run compare].include?(@mode)
483
+
484
+ OptionParser.new do |parser|
485
+ %w[baseline-root baseline-sha candidate-root candidate-sha profile budgets output input].each do |name|
486
+ parser.on("--#{name} VALUE") { |value| @options[name.tr('-', '_').to_sym] = value }
487
+ end
488
+ end.parse!(@argv)
489
+ raise ContractError, 'Unexpected arguments' unless @argv.empty?
490
+
491
+ PROFILES.fetch(@options.fetch(:profile))
492
+ required = @mode == 'run' ? %i[baseline_root baseline_sha candidate_root candidate_sha budgets output] : %i[input budgets output]
493
+ required.each { |key| @options.fetch(key) }
494
+ end
495
+
496
+ def worker
497
+ raise ContractError, 'Unexpected worker arguments' unless @argv.empty?
498
+
499
+ result = Worker.new(Identity.parse($stdin.read)).run
500
+ serialization_started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
501
+ serialized = JSON.generate(result)
502
+ serialization_seconds = Process.clock_gettime(Process::CLOCK_MONOTONIC) - serialization_started
503
+ # Add the serialization receipt without serializing the response trees a
504
+ # second time solely to report the first serialization's duration.
505
+ puts "#{serialized.delete_suffix('}')},\"serialization_seconds\":#{serialization_seconds}}"
506
+ 0
507
+ end
508
+
509
+ def git(*args)
510
+ output, status = Open3.capture2('git', '-C', ROOT, *args, err: File::NULL)
511
+ raise ContractError, "Cannot verify source object: git #{args.first}" unless status.success?
512
+
513
+ output
514
+ end
515
+
516
+ def ancestor?(left, right)
517
+ _output, status = Open3.capture2('git', '-C', ROOT, 'merge-base', '--is-ancestor', left, right, err: File::NULL)
518
+ raise ContractError, 'Cannot establish source ancestry' unless [0, 1].include?(status.exitstatus)
519
+
520
+ status.success?
521
+ end
522
+
523
+ def verify_source(role)
524
+ sha = @options.fetch(:"#{role}_sha")
525
+ raise ContractError, 'Source SHA must be full immutable SHA' unless sha.match?(/\A[0-9a-f]{40}\z/) && sha != '0' * 40
526
+
527
+ root = File.realpath(@options.fetch(:"#{role}_root"))
528
+ git('cat-file', '-e', "#{sha}^{commit}")
529
+ files = git('ls-tree', '-rz', '--full-tree', sha).split("\0")
530
+ graph = {}
531
+ files.each do |entry|
532
+ metadata, path = entry.split("\t", 2)
533
+ mode, kind, expected = metadata.split
534
+ raise ContractError, "Unsupported source entry #{path}" unless kind == 'blob'
535
+
536
+ target = File.join(root, path)
537
+ contents = mode == '120000' ? File.readlink(target) : File.binread(target)
538
+ actual = Digest::SHA1.hexdigest("blob #{contents.bytesize}\0#{contents}")
539
+ raise ContractError, "Source tree mismatch #{role}:#{path}" unless actual == expected
540
+
541
+ graph[path] = Digest::SHA256.hexdigest(contents)
542
+ end
543
+ { root:, sha:, tracked_files: files.length, files_sha256: graph, tree: git('rev-parse', "#{sha}^{tree}").strip }
544
+ end
545
+
546
+ def run_samples(digest)
547
+ policy = PROFILES.fetch(@options.fetch(:profile))
548
+ Performance.validate_source_pair(profile: @options[:profile], baseline_sha: @options[:baseline_sha],
549
+ candidate_sha: @options[:candidate_sha])
550
+
551
+ sources = %w[baseline candidate].to_h { |role| [role, verify_source(role)] }
552
+ @sources = JSON.parse(JSON.generate(sources))
553
+ @release_lineage = Release.lineage(git: method(:git), ancestor: method(:ancestor?), candidate: @options[:candidate_sha])
554
+ unless ancestor?(@options[:baseline_sha], @options[:candidate_sha]) ||
555
+ (@options[:profile] == 'weekly' && @options[:baseline_sha] == REFERENCE && @release_lineage)
556
+ raise ContractError, 'Baseline must be an ancestor of candidate'
557
+ end
558
+
559
+ pilot = child('baseline', {}, 'pilot')
560
+ inventory = pilot.fetch('inventory')
561
+ counts = pilot.fetch('cases').to_h do |item|
562
+ count = (policy.fetch(:target) / item.fetch('wall_seconds')).ceil.clamp(1, MAX_ITERATIONS)
563
+ [Identity.key(item), count]
564
+ end
565
+ warmup_counts = pilot.fetch('cases').to_h do |item|
566
+ count = item.fetch('wall_seconds') >= policy.fetch(:target) ? 0 : (0.100 / item.fetch('wall_seconds')).ceil.clamp(1, counts.fetch(Identity.key(item)))
567
+ [Identity.key(item), count]
568
+ end
569
+ @warmup_counts = warmup_counts
570
+ calibration = child('baseline', counts, 'calibration')
571
+ adjusted = false
572
+ calibration.fetch('cases').each do |item|
573
+ key = Identity.key(item)
574
+ duration = item.fetch('wall_seconds') * counts.fetch(key)
575
+ next if warmup_counts.fetch(key) == 0 || duration >= policy.fetch(:adequate) || counts.fetch(key) == MAX_ITERATIONS
576
+
577
+ counts[key] = (counts.fetch(key) * policy.fetch(:target) / duration).ceil.clamp(1, MAX_ITERATIONS)
578
+ adjusted = true
579
+ end
580
+ calibration = child('baseline', counts, 'calibration-adjusted') if adjusted
581
+ cases = inventory.map do |spec|
582
+ observed = calibration.fetch('cases').find { |item| Identity.key(item) == Identity.key(spec) }
583
+ count = counts.fetch(Identity.key(spec))
584
+ duration = observed.fetch('wall_seconds') * count
585
+ spec.merge('iterations' => count, 'pilot' => observed.merge('selected_iterations' => count,
586
+ 'warmup_iterations' => warmup_counts.fetch(Identity.key(spec)), 'target_seconds' => policy.fetch(:target),
587
+ 'baseline_batch_seconds' => duration, 'adequate_signal' => duration >= policy.fetch(:adequate),
588
+ 'capped' => count == MAX_ITERATIONS, 'calibration_adjusted' => adjusted), 'self_pairs' => [], 'pairs' => [])
589
+ end
590
+ policy[:self_pairs].times do |index|
591
+ workload_order = index.even? ? 'forward' : 'reverse'
592
+ left = child('baseline', counts, "aa-#{index}-a", workload_order)
593
+ right = child('baseline', counts, "aa-#{index}-b", workload_order)
594
+ append_pair(cases, 'self_pairs', %w[baseline baseline], left, right, workload_order)
595
+ end
596
+ policy[:pairs].times do |index|
597
+ order = index.even? ? %w[baseline candidate] : %w[candidate baseline]
598
+ workload_order = index.even? ? 'forward' : 'reverse'
599
+ observations = order.map { |role| child(role, counts, "ab-#{index}-#{role}", workload_order) }
600
+ baseline = observations.fetch(order.index('baseline'))
601
+ candidate = observations.fetch(order.index('candidate'))
602
+ append_pair(cases, 'pairs', order, baseline, candidate, workload_order)
603
+ end
604
+ { 'schema_version' => SAMPLE_SCHEMA, 'profile' => @options[:profile], 'baseline_sha' => @options[:baseline_sha],
605
+ 'candidate_sha' => @options[:candidate_sha], 'harness_sha256' => Identity.digest(Worker.fixture_hashes),
606
+ 'fixture_sha256' => Worker.fixture_hashes, 'budget_sha256' => digest, 'sources' => @sources,
607
+ 'environment' => @common.fetch('environment'), 'inputs' => @common.fetch('inputs'), 'release_lineage' => @release_lineage,
608
+ 'observations' => @observations,
609
+ 'inventory' => inventory, 'cases' => cases }
610
+ end
611
+
612
+ def append_pair(cases, name, order, left, right, workload_order)
613
+ cases.each do |item|
614
+ key = Identity.key(item)
615
+ a = left.fetch('cases').find { |entry| Identity.key(entry) == key }
616
+ b = right.fetch('cases').find { |entry| Identity.key(entry) == key }
617
+ raise ContractError, 'Worker omitted a case' unless a && b
618
+
619
+ item[name] << { 'order' => order, 'workload_order' => workload_order, 'baseline' => a, 'candidate' => b }
620
+ end
621
+ end
622
+
623
+ def child(role, counts, label, workload_order = 'forward')
624
+ request = { source_root: File.realpath(@options.fetch(:"#{role}_root")), source_sha: @options.fetch(:"#{role}_sha"),
625
+ profile: @options[:profile], workload_order:, counts:, warmup_counts: @warmup_counts || {}, release_versions: !@release_lineage.nil? }
626
+ env = ENV.keys.grep(/\ABUNDLE|\ABUNDLER|\ARUBY(?:OPT|LIB)\z/).to_h { |key| [key, nil] }
627
+ env.merge!('LC_ALL' => 'C.UTF-8', 'LANG' => 'C.UTF-8', 'TZ' => 'UTC', 'RUBY_YJIT_ENABLE' => '0', 'RACK_ENV' => 'test')
628
+ process_started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
629
+ output, errors, status = capture_worker(env, request, label)
630
+ process_wall_seconds = Process.clock_gettime(Process::CLOCK_MONOTONIC) - process_started
631
+ File.write(File.join(@output, "#{label}.stderr.log"), errors)
632
+ File.write(File.join(@output, "#{label}.json"), output)
633
+ raise ContractError, "Worker failed #{label} (#{status.exitstatus}): #{errors.lines.last}" unless status.success?
634
+
635
+ result = JSON.parse(output)
636
+ raise ContractError, 'Unsupported worker sample schema' unless result.fetch('schema_version').eql?(SAMPLE_SCHEMA)
637
+ raise ContractError, 'Worker source identity mismatch' unless result.fetch('source').fetch('root') == request[:source_root] && result['source']['sha'] == request[:source_sha]
638
+ raise ContractError, 'Worker fixture graph mismatch' unless result.fetch('fixture_sha256') == Worker.fixture_hashes
639
+
640
+ result.fetch('source').fetch('loaded_sources').each do |path, sha256|
641
+ unless @sources.fetch(role).fetch('files_sha256').fetch(path) == sha256
642
+ raise ContractError, 'Loaded runtime differs from exact source object'
643
+ end
644
+ end
645
+
646
+ inputs = result.fetch('inputs')
647
+ if @release_lineage
648
+ Release.validate_inputs(inputs, @sources.fetch(role), @options.fetch(:profile))
649
+ elsif inputs.has_key?('release_versions')
650
+ raise ContractError, 'Unproven own-version normalization'
651
+ else
652
+ Release.validate_json_preparation(inputs, @sources.fetch(role))
653
+ end
654
+ common = result.slice('environment', 'inventory', 'fixture_sha256').merge('inputs' => Release.graph(inputs))
655
+ @common ||= common
656
+ raise ContractError, 'Incompatible dependency/runtime/input/workload graph' unless common == @common
657
+
658
+ @observations ||= {}
659
+ @observations[label] = result.slice('source', 'environment', 'inputs', 'fixture_sha256', 'runtime_policy', 'host',
660
+ 'workload_order', 'stages', 'serialization_seconds').merge('process_wall_seconds' => process_wall_seconds)
661
+ result.fetch('cases').each { |item| item['observation'] = label }
662
+ result
663
+ end
664
+
665
+ def worker_command
666
+ [RbConfig.ruby, '--disable-yjit', __FILE__, 'worker']
667
+ end
668
+
669
+ def capture_worker(env, request, label)
670
+ reader, writer = IO.pipe
671
+ request = request.merge(progress_fd: writer.fileno)
672
+ output = errors = ''
673
+ status = nil
674
+ Open3.popen3(env, *worker_command, writer.fileno => writer, pgroup: true) do |input, stdout, stderr, waiter|
675
+ writer.close
676
+ out_thread = Thread.new { stdout.read }
677
+ err_thread = Thread.new { stderr.read }
678
+ begin
679
+ input.write(JSON.generate(request))
680
+ input.close
681
+ event = { 'state' => 'setup' }
682
+ File.open(File.join(@output, "#{label}.progress.jsonl"), 'w') do |log|
683
+ loop do
684
+ limit = event.fetch('state') == 'request' ? REQUEST_LIMIT_SECONDS : SETUP_LIMIT_SECONDS
685
+ unless reader.wait_readable(limit)
686
+ raise WorkerTimeout.new(kind: event['state'] == 'request' ? 'request_batch_timeout' : 'worker_setup_timeout',
687
+ source_sha: request.fetch(:source_sha), workload: event['workload'],
688
+ phase: "#{label}/#{event['phase']}", limit_seconds: limit)
689
+ end
690
+ line = reader.gets
691
+ break unless line
692
+
693
+ log.write(line)
694
+ log.flush
695
+ event = JSON.parse(line)
696
+ raise ContractError, 'Invalid worker progress' unless %w[request setup].include?(event.fetch('state'))
697
+ end
698
+ end
699
+ unless waiter.join(SETUP_LIMIT_SECONDS)
700
+ raise WorkerTimeout.new(kind: 'worker_setup_timeout', source_sha: request.fetch(:source_sha),
701
+ workload: event['workload'], phase: "#{label}/serialization", limit_seconds: SETUP_LIMIT_SECONDS)
702
+ end
703
+ status = waiter.value
704
+ ensure
705
+ unless waiter.join(0)
706
+ begin
707
+ Process.kill('TERM', -waiter.pid)
708
+ waiter.join(0.2)
709
+ Process.kill('KILL', -waiter.pid)
710
+ rescue Errno::ESRCH
711
+ # The owned process group has exited.
712
+ end
713
+ waiter.value
714
+ end
715
+ output = out_thread.value
716
+ errors = err_thread.value
717
+ File.write(File.join(@output, "#{label}.stderr.log"), errors)
718
+ File.write(File.join(@output, "#{label}.json"), output)
719
+ end
720
+ end
721
+ [output, errors, status]
722
+ ensure
723
+ reader&.close unless reader&.closed?
724
+ writer&.close unless writer&.closed?
725
+ end
726
+ end
727
+ end
728
+ end
729
+
730
+ exit HaveAPI::Performance::CLI.new(ARGV).execute if $0 == __FILE__