haveapi 0.30.2 → 0.31.1
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.
- checksums.yaml +4 -4
- data/benchmarks/Gemfile +30 -0
- data/benchmarks/Gemfile.lock +296 -0
- data/benchmarks/endpoints.rb +77 -21
- data/benchmarks/performance_compare.rb +204 -19
- data/benchmarks/shell.nix +21 -0
- data/benchmarks/spec/client_examples_spec.rb +216 -0
- data/doc/client-examples.md +88 -35
- data/doc/performance.md +163 -61
- data/haveapi.gemspec +1 -1
- data/lib/haveapi/client_examples/ts_generated_client.rb +36 -0
- data/lib/haveapi/documentation_clients.rb +49 -6
- data/lib/haveapi/version.rb +1 -1
- data/lib/haveapi/views/version_page.erb +2 -2
- data/spec/client_examples/profile_mount_spec.rb +45 -0
- data/spec/client_examples/ts_generated_client_spec.rb +53 -0
- data/spec/client_examples/typescript_profiles_spec.rb +113 -0
- data/spec/documentation/client_examples_spec.rb +2 -2
- data/spec/performance/comparator_spec.rb +136 -6
- data/spec/performance/event_baseline_spec.rb +185 -30
- data/spec/performance/release_lineage_spec.rb +53 -14
- data/spec/performance/runner_spec.rb +136 -0
- data/spec/performance/source_versions_spec.rb +168 -0
- data/test_support/all_client_performance_fixture.rb +84 -0
- metadata +12 -3
|
@@ -8,6 +8,8 @@ module HaveAPI
|
|
|
8
8
|
end
|
|
9
9
|
require 'digest'
|
|
10
10
|
require 'json'
|
|
11
|
+
require 'open3'
|
|
12
|
+
require 'rbconfig'
|
|
11
13
|
|
|
12
14
|
module HaveAPI
|
|
13
15
|
module Performance
|
|
@@ -18,6 +20,7 @@ module HaveAPI
|
|
|
18
20
|
MAX_ITERATIONS = 100_000
|
|
19
21
|
REQUEST_LIMIT_SECONDS = 30.0
|
|
20
22
|
SETUP_LIMIT_SECONDS = 120.0
|
|
23
|
+
BENCHMARK_INPUTS = %w[servers/ruby/benchmarks/Gemfile servers/ruby/benchmarks/Gemfile.lock servers/ruby/benchmarks/shell.nix].freeze
|
|
21
24
|
|
|
22
25
|
def self.complex_cases(profile)
|
|
23
26
|
sizes = profile == 'weekly' ? [32] : [16]
|
|
@@ -30,6 +33,34 @@ module HaveAPI
|
|
|
30
33
|
RUNTIME_POLICY = { 'rack_env' => 'test', 'locale' => 'C.UTF-8', 'timezone' => 'UTC', 'yjit' => false, 'rjit' => false }.freeze
|
|
31
34
|
class ContractError < StandardError; end
|
|
32
35
|
|
|
36
|
+
def self.gemspec(root, path)
|
|
37
|
+
script = <<~RUBY
|
|
38
|
+
require 'json'
|
|
39
|
+
spec = Gem::Specification.load(File.basename(ARGV.fetch(0)))
|
|
40
|
+
abort 'Cannot load gemspec' unless spec
|
|
41
|
+
puts JSON.generate(name: spec.name, version: spec.version.to_s, ruby: spec.required_ruby_version.to_s,
|
|
42
|
+
dependencies: spec.dependencies.map { |d| [d.name, d.type.to_s, d.requirement.to_s] }.sort)
|
|
43
|
+
RUBY
|
|
44
|
+
environment = (ENV.keys.grep(/\ABUNDLE/) + %w[RUBYOPT RUBYLIB RUBYGEMS_GEMDEPS]).to_h { |key| [key, nil] }
|
|
45
|
+
output, errors, status = Open3.capture3(environment, RbConfig.ruby, '-e', script, path, chdir: File.dirname(File.join(root, path)))
|
|
46
|
+
raise ContractError, "Cannot load source gemspec #{path}: #{errors.strip}" unless status.success?
|
|
47
|
+
|
|
48
|
+
specification = JSON.parse(output)
|
|
49
|
+
unless Gem::Requirement.new(specification.fetch('ruby')).satisfied_by?(Gem::Version.new(RUBY_VERSION))
|
|
50
|
+
raise ContractError, "Benchmark Ruby does not satisfy #{path}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
specification.fetch('dependencies').each do |name, type, requirement|
|
|
54
|
+
next unless type == 'runtime'
|
|
55
|
+
next if %w[haveapi haveapi-client haveapi-rust-client].include?(name)
|
|
56
|
+
|
|
57
|
+
unless Gem::Requirement.new(requirement.split(',').map(&:strip)).satisfied_by?(Gem::Specification.find_by_name(name).version)
|
|
58
|
+
raise ContractError, "Benchmark dependencies do not satisfy #{name} #{requirement}"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
specification
|
|
62
|
+
end
|
|
63
|
+
|
|
33
64
|
# One reviewed maintenance mapping, not a general cherry-pick certificate.
|
|
34
65
|
# Both live entry points verify Git; replay verifies the retained raw delta.
|
|
35
66
|
module Release
|
|
@@ -166,7 +197,10 @@ module HaveAPI
|
|
|
166
197
|
receipt = inputs.fetch('release_versions')
|
|
167
198
|
raise ContractError, 'Incomplete release input graph' unless receipt.fetch('contents').keys.sort == paths(profile).sort
|
|
168
199
|
raise ContractError, 'Release input normalization mismatch' unless receipt == capture(receipt.fetch('contents'))
|
|
169
|
-
|
|
200
|
+
|
|
201
|
+
expected = receipt.fetch('normalized_sha256')
|
|
202
|
+
expected = expected.except('flake.nix', 'flake.lock') if inputs.has_key?('benchmark')
|
|
203
|
+
raise ContractError, 'Release normalized graph mismatch' unless inputs.fetch('manifests') == expected
|
|
170
204
|
|
|
171
205
|
receipt.fetch('raw_sha256').each do |path, digest|
|
|
172
206
|
raise ContractError, 'Release input escaped verified source' unless source.fetch('files_sha256').fetch(path) == digest
|
|
@@ -175,7 +209,7 @@ module HaveAPI
|
|
|
175
209
|
end
|
|
176
210
|
|
|
177
211
|
def self.graph(inputs)
|
|
178
|
-
inputs.except('release_versions', 'json_preparation')
|
|
212
|
+
inputs.except('release_versions', 'source_versions', 'json_preparation', 'contributor_flakes')
|
|
179
213
|
end
|
|
180
214
|
|
|
181
215
|
def self.html(body, own)
|
|
@@ -204,6 +238,103 @@ module HaveAPI
|
|
|
204
238
|
end
|
|
205
239
|
end
|
|
206
240
|
|
|
241
|
+
# Ordinary source ancestry needs no historical maintenance certificate.
|
|
242
|
+
# Only synchronized package metadata is omitted from input equality.
|
|
243
|
+
module SourceVersions
|
|
244
|
+
PLACEHOLDER = 'OWN.VERSION'.freeze
|
|
245
|
+
COMPONENTS = Release::VERSIONS
|
|
246
|
+
SPECS = COMPONENTS.to_h { |path, name| ["#{path.split('/lib/').first}/#{name}.gemspec", name] }.freeze
|
|
247
|
+
PATHS = (['VERSION'] + COMPONENTS.keys + SPECS.keys).freeze
|
|
248
|
+
|
|
249
|
+
def self.capture(contents)
|
|
250
|
+
raise ContractError, 'Incomplete own source metadata' unless contents.keys.sort == PATHS.sort
|
|
251
|
+
|
|
252
|
+
own = contents.fetch('VERSION').delete_suffix("\n")
|
|
253
|
+
unless own.match?(/\A(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\z/) && contents['VERSION'] == "#{own}\n"
|
|
254
|
+
raise ContractError, 'Invalid own source VERSION'
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
normalized = contents.merge('VERSION' => "#{PLACEHOLDER}\n")
|
|
258
|
+
COMPONENTS.each do |path, name|
|
|
259
|
+
text = contents.fetch(path)
|
|
260
|
+
declaration = /^(\s*VERSION = )'#{Regexp.escape(own)}'(?=\.freeze$)/
|
|
261
|
+
unless text.scan(/^\s*VERSION\s*=/).length == 1 && text.scan(declaration).length == 1
|
|
262
|
+
raise ContractError, "Unsynchronized own version: #{name}"
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
normalized[path] = text.sub(declaration, "\\1'#{PLACEHOLDER}'")
|
|
266
|
+
end
|
|
267
|
+
SPECS.each do |path, name|
|
|
268
|
+
text = contents.fetch(path)
|
|
269
|
+
constant = { 'haveapi' => 'HaveAPI', 'haveapi-client' => 'HaveAPI::Client', 'haveapi-rust-client' => 'HaveAPI::RustClient' }.fetch(name)
|
|
270
|
+
unless text.scan(/\.name\s*=/).length == 1 && text.scan(/\.name\s*= '#{Regexp.escape(name)}'$/).length == 1 &&
|
|
271
|
+
text.scan(/\.version\s*=/).length == 1 && text.scan(/\.version\s*= #{Regexp.escape(constant)}::VERSION$/).length == 1
|
|
272
|
+
raise ContractError, 'Mismatched own package identity'
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
dependencies = text.scan(/\.add_(?:runtime_|development_)?dependency\s+['"]haveapi-client['"]/)
|
|
276
|
+
requirement = /(?<=\.add_dependency )'haveapi-client', '~> #{Regexp.escape(own)}'(?=$)/
|
|
277
|
+
if name == 'haveapi-client'
|
|
278
|
+
raise ContractError, 'Unexpected own client dependency' unless dependencies.empty?
|
|
279
|
+
else
|
|
280
|
+
unless dependencies.length == 1 && text.scan(requirement).length == 1
|
|
281
|
+
raise ContractError, 'Unsynchronized own client constraint'
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
normalized[path] = text.sub(requirement, "'haveapi-client', '~> #{PLACEHOLDER}'")
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
{ 'contents' => contents, 'own_version' => own,
|
|
288
|
+
'raw_sha256' => contents.transform_values { |text| Digest::SHA256.hexdigest(text) },
|
|
289
|
+
'normalized_sha256' => normalized.transform_values { |text| Digest::SHA256.hexdigest(text) } }
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def self.gemspec(root, path, receipt)
|
|
293
|
+
spec = Performance.gemspec(root, path)
|
|
294
|
+
own = receipt.fetch('own_version')
|
|
295
|
+
unless spec.fetch('name') == SPECS.fetch(path) && spec.fetch('version') == own
|
|
296
|
+
raise ContractError, 'Evaluated own package identity mismatch'
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
dependencies = spec.fetch('dependencies').select { |name, _type, _requirement| name == 'haveapi-client' }
|
|
300
|
+
expected = SPECS.fetch(path) == 'haveapi-client' ? [] : [['haveapi-client', 'runtime', "~> #{own}"]]
|
|
301
|
+
raise ContractError, 'Evaluated own client constraint mismatch' unless dependencies == expected
|
|
302
|
+
|
|
303
|
+
spec.merge('version' => PLACEHOLDER, 'dependencies' => spec.fetch('dependencies').map do |name, type, requirement|
|
|
304
|
+
[name, type, name == 'haveapi-client' ? "~> #{PLACEHOLDER}" : requirement]
|
|
305
|
+
end)
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def self.validate_inputs(inputs, source)
|
|
309
|
+
receipt = inputs.fetch('source_versions')
|
|
310
|
+
raise ContractError, 'Own source metadata normalization mismatch' unless receipt == capture(receipt.fetch('contents'))
|
|
311
|
+
|
|
312
|
+
receipt.fetch('raw_sha256').each do |path, digest|
|
|
313
|
+
unless source.fetch('files_sha256').fetch(path) == digest && inputs.fetch('manifests').fetch(path) == receipt.fetch('normalized_sha256').fetch(path)
|
|
314
|
+
raise ContractError, 'Own source metadata escaped verified source'
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def self.validate_input_receipts(inputs, source, profile:, lineage:)
|
|
321
|
+
if lineage
|
|
322
|
+
raise ContractError, 'Conflicting own-version receipts' if inputs.has_key?('source_versions')
|
|
323
|
+
|
|
324
|
+
Release.validate_inputs(inputs, source, profile)
|
|
325
|
+
else
|
|
326
|
+
raise ContractError, 'Unproven own-version normalization' if inputs.has_key?('release_versions')
|
|
327
|
+
|
|
328
|
+
Release.validate_json_preparation(inputs, source)
|
|
329
|
+
SourceVersions.validate_inputs(inputs, source) if inputs.has_key?('source_versions')
|
|
330
|
+
inputs.fetch('manifests').each do |path, digest|
|
|
331
|
+
next if Release::JSON_GEMFILES.include?(path) || (inputs.has_key?('source_versions') && SourceVersions::PATHS.include?(path))
|
|
332
|
+
|
|
333
|
+
raise ContractError, 'Dependency manifest escaped source' unless source.fetch('files_sha256').fetch(path) == digest
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
|
|
207
338
|
def self.validate_source_pair(profile:, baseline_sha:, candidate_sha:)
|
|
208
339
|
raise ContractError, 'Baseline and candidate must differ' if baseline_sha == candidate_sha
|
|
209
340
|
return unless profile == 'weekly' && baseline_sha != REFERENCE
|
|
@@ -211,6 +342,19 @@ module HaveAPI
|
|
|
211
342
|
raise ContractError, 'Weekly baseline must be the fixed reference'
|
|
212
343
|
end
|
|
213
344
|
|
|
345
|
+
def self.push_pair!(selection, baseline:, candidate:)
|
|
346
|
+
return false unless selection['push_pair']
|
|
347
|
+
|
|
348
|
+
expected = { 'event_name' => 'push', 'before' => baseline, 'after' => candidate }
|
|
349
|
+
unless selection['event_name'] == 'push' && selection['profile'] == 'quick' && selection['push_pair'] == expected &&
|
|
350
|
+
selection['baseline_sha'] == baseline && selection['candidate_sha'] == candidate && baseline != candidate &&
|
|
351
|
+
[baseline, candidate].all? { |sha| sha.is_a?(String) && sha.match?(/\A[0-9a-f]{40}\z/) && sha != '0' * 40 }
|
|
352
|
+
raise ContractError, 'Invalid quick push event pair'
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
true
|
|
356
|
+
end
|
|
357
|
+
|
|
214
358
|
# Canonicalization ignores map order only. Arrays and scalar types retain
|
|
215
359
|
# their meaning; duplicate JSON or symbol/string-normalized keys fail closed.
|
|
216
360
|
module Identity
|
|
@@ -270,6 +414,9 @@ module HaveAPI
|
|
|
270
414
|
end
|
|
271
415
|
|
|
272
416
|
class Comparator
|
|
417
|
+
POLICY = 'per-revision-response-stability-v1'.freeze
|
|
418
|
+
SOURCE_SHA256 = Digest::SHA256.file(__FILE__).hexdigest.freeze
|
|
419
|
+
|
|
273
420
|
def initialize(samples, budgets, budget_sha256:)
|
|
274
421
|
@samples = samples
|
|
275
422
|
@budgets = budgets
|
|
@@ -357,13 +504,33 @@ module HaveAPI
|
|
|
357
504
|
raise ContractError, 'Loaded runtime not in verified source tree' unless selected.fetch('files_sha256').fetch(path) == sha256
|
|
358
505
|
end
|
|
359
506
|
inputs = observation.fetch('inputs')
|
|
360
|
-
if
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
507
|
+
if inputs.has_key?('benchmark')
|
|
508
|
+
tool = inputs.fetch('benchmark')
|
|
509
|
+
raise ContractError, 'Incomplete benchmark inputs' unless tool.keys.sort == BENCHMARK_INPUTS.sort
|
|
510
|
+
|
|
511
|
+
tool.each do |path, input|
|
|
512
|
+
unless Digest::SHA256.hexdigest(input.fetch('content')) == input.fetch('sha256') &&
|
|
513
|
+
@samples.fetch('inputs').fetch('benchmark').fetch(path) == input
|
|
514
|
+
raise ContractError, 'Benchmark input digest mismatch'
|
|
515
|
+
end
|
|
516
|
+
end
|
|
517
|
+
unless inputs.fetch('locks') == { 'servers/ruby/benchmarks/Gemfile.lock' => tool.fetch('servers/ruby/benchmarks/Gemfile.lock').fetch('sha256') }
|
|
518
|
+
raise ContractError, 'Benchmark lock identity mismatch'
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
inputs.fetch('contributor_flakes').each do |path, input|
|
|
522
|
+
unless %w[flake.nix flake.lock].include?(path) && Digest::SHA256.hexdigest(input.fetch('content')) == selected.fetch('files_sha256').fetch(path)
|
|
523
|
+
raise ContractError, 'Contributor flake escaped source'
|
|
524
|
+
end
|
|
525
|
+
end
|
|
526
|
+
unless inputs.fetch('contributor_flakes').keys.sort == %w[flake.lock flake.nix]
|
|
527
|
+
raise ContractError, 'Missing contributor flake provenance'
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
elsif inputs.has_key?('contributor_flakes')
|
|
531
|
+
raise ContractError, 'Contributor flake separation without benchmark inputs'
|
|
366
532
|
end
|
|
533
|
+
Performance.validate_input_receipts(inputs, selected, profile: @samples.fetch('profile'), lineage:)
|
|
367
534
|
unless observation.fetch('environment') == @samples['environment'] && Release.graph(inputs) == @samples.fetch('inputs') &&
|
|
368
535
|
observation.fetch('fixture_sha256') == @samples['fixture_sha256']
|
|
369
536
|
raise ContractError, 'Worker provenance mismatch'
|
|
@@ -474,14 +641,28 @@ module HaveAPI
|
|
|
474
641
|
end
|
|
475
642
|
end
|
|
476
643
|
|
|
644
|
+
def output_counts(outputs, digest = 'canonical_sha256')
|
|
645
|
+
outputs.each_with_object(Hash.new(0)) do |output, counts|
|
|
646
|
+
identity = JSON.generate(output.values_at('http_status', 'envelope_status', digest))
|
|
647
|
+
counts[identity] += output.fetch('count')
|
|
648
|
+
end
|
|
649
|
+
end
|
|
650
|
+
|
|
477
651
|
def same_outputs(left, right)
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
652
|
+
output_counts(left) == output_counts(right)
|
|
653
|
+
end
|
|
654
|
+
|
|
655
|
+
def response_changes(pairs)
|
|
656
|
+
outputs = %w[baseline candidate].to_h do |role|
|
|
657
|
+
[role, pairs.flat_map { |pair| pair.fetch(role).fetch('outputs') }]
|
|
483
658
|
end
|
|
484
|
-
|
|
659
|
+
bytes = outputs.transform_values do |examples|
|
|
660
|
+
examples.sum { |output| output.fetch('body_bytes') * output.fetch('count') }.to_f / examples.sum { |output| output.fetch('count') }
|
|
661
|
+
end
|
|
662
|
+
delta = bytes.fetch('candidate') - bytes.fetch('baseline')
|
|
663
|
+
{ 'raw_changed' => output_counts(outputs.fetch('baseline'), 'body_sha256') != output_counts(outputs.fetch('candidate'), 'body_sha256'),
|
|
664
|
+
'canonical_changed' => !same_outputs(outputs.fetch('baseline'), outputs.fetch('candidate')),
|
|
665
|
+
'bytes_per_request' => bytes.merge('delta' => delta, 'delta_percent' => 100.0 * delta / bytes.fetch('baseline')) }
|
|
485
666
|
end
|
|
486
667
|
|
|
487
668
|
def compare_case(item, budget)
|
|
@@ -499,7 +680,8 @@ module HaveAPI
|
|
|
499
680
|
raise ContractError, 'Uncalibrated allocation ceiling' unless reference > 0 && ceiling == (reference * 1.25).ceil + 32
|
|
500
681
|
end
|
|
501
682
|
|
|
502
|
-
|
|
683
|
+
outputs_by_source = {}
|
|
684
|
+
canonical_counters = nil
|
|
503
685
|
(self_pairs + pairs).each_with_index do |pair, index|
|
|
504
686
|
expected_order = if index < self_pairs.length
|
|
505
687
|
%w[baseline baseline]
|
|
@@ -530,8 +712,10 @@ module HaveAPI
|
|
|
530
712
|
raise ContractError, 'Worker workload order mismatch'
|
|
531
713
|
end
|
|
532
714
|
|
|
533
|
-
|
|
534
|
-
|
|
715
|
+
outputs_by_source[actual_sha] ||= sample.fetch('outputs')
|
|
716
|
+
unless same_outputs(outputs_by_source.fetch(actual_sha), sample.fetch('outputs'))
|
|
717
|
+
raise ContractError, "Response identity differs within revision #{actual_sha}"
|
|
718
|
+
end
|
|
535
719
|
end
|
|
536
720
|
end
|
|
537
721
|
classifications = %w[wall_seconds cpu_seconds].to_h do |metric|
|
|
@@ -566,9 +750,10 @@ module HaveAPI
|
|
|
566
750
|
'pass'
|
|
567
751
|
end
|
|
568
752
|
item.slice('id', 'dimensions', 'iterations')
|
|
569
|
-
.merge('classification' => classification, 'timing' => classifications,
|
|
753
|
+
.merge('classification' => classification, 'timing' => classifications, 'responses' => response_changes(pairs),
|
|
570
754
|
'correctness' => { 'status' => 'pass', 'validated_samples' => 2 * (self_pairs.length + pairs.length),
|
|
571
|
-
'operations_per_sample' => iterations, 'distinct_responses' =>
|
|
755
|
+
'operations_per_sample' => iterations, 'distinct_responses' => outputs_by_source.fetch(@samples.fetch('baseline_sha')).length,
|
|
756
|
+
'distinct_responses_by_source' => outputs_by_source.transform_values(&:length) },
|
|
572
757
|
'pilot' => item.fetch('pilot', {}).slice('selected_iterations', 'warmup_iterations', 'target_seconds',
|
|
573
758
|
'baseline_batch_seconds', 'adequate_signal', 'capped', 'calibration_adjusted'),
|
|
574
759
|
'allocations' => { 'baseline_median' => base_allocations, 'candidate_median' => candidate_allocations,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
let
|
|
2
|
+
pkgs = import (builtins.fetchTree {
|
|
3
|
+
type = "github";
|
|
4
|
+
owner = "NixOS";
|
|
5
|
+
repo = "nixpkgs";
|
|
6
|
+
rev = "02c087f3fe673c9e317f206a627cb6b910336dbe";
|
|
7
|
+
narHash = "sha256-IoNisKcGbUjdd45qCqywGMCYmZ5EnkxezvLII0FWwNA=";
|
|
8
|
+
}) { };
|
|
9
|
+
in
|
|
10
|
+
pkgs.mkShell {
|
|
11
|
+
name = "haveapi-benchmarks";
|
|
12
|
+
packages = with pkgs; [ ruby_3_4 git gnumake pkg-config openssl libffi ];
|
|
13
|
+
LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [ pkgs.stdenv.cc.cc.lib ];
|
|
14
|
+
shellHook = ''
|
|
15
|
+
for name in ''${!BUNDLE@}; do unset "$name"; done
|
|
16
|
+
unset RUBYOPT RUBYLIB RUBYGEMS_GEMDEPS
|
|
17
|
+
export GEM_HOME="''${HAVEAPI_BENCHMARK_GEM_HOME:-${toString ./.}/.gems}"
|
|
18
|
+
export GEM_PATH="$GEM_HOME:${pkgs.ruby_3_4}/lib/ruby/gems/3.4.0"
|
|
19
|
+
export PATH="$GEM_HOME/bin:$PATH"
|
|
20
|
+
'';
|
|
21
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
# Run unbundled in the locked benchmark shell; optional generators are required.
|
|
2
|
+
# One shared mounted fixture excludes setup from the per-render measurements.
|
|
3
|
+
# rubocop:disable RSpec/InstanceVariable, RSpec/LeakyLocalVariable
|
|
4
|
+
require_relative '../performance_compare'
|
|
5
|
+
root = File.expand_path('../../../..', __dir__)
|
|
6
|
+
%w[servers/ruby clients/ruby clients/go clients/rust clients/typescript].each do |component|
|
|
7
|
+
$:.unshift(File.join(root, component, 'lib'))
|
|
8
|
+
end
|
|
9
|
+
require 'haveapi'
|
|
10
|
+
require 'rack/mock'
|
|
11
|
+
require 'base64'
|
|
12
|
+
require 'cgi'
|
|
13
|
+
require_relative '../../test_support/all_client_performance_fixture'
|
|
14
|
+
|
|
15
|
+
RSpec.configure { |config| config.fail_if_no_examples = true }
|
|
16
|
+
|
|
17
|
+
RSpec.describe HaveAPI::ClientExample, '.render' do
|
|
18
|
+
# Each mode has its own registry entry. New entries need reviewed expectations
|
|
19
|
+
# and a real supported profile here, rather than silently missing measurement.
|
|
20
|
+
profiles = {
|
|
21
|
+
'0' => nil, '2' => nil, '3' => nil, '5' => nil, '6' => nil,
|
|
22
|
+
'javascript-callback' => nil, 'javascript-async' => nil,
|
|
23
|
+
'typescript-callback' => nil, 'typescript-async' => nil,
|
|
24
|
+
'python-sync' => nil, 'python-async' => nil,
|
|
25
|
+
'go' => :go, 'rust-async' => :rust, 'rust-blocking' => :rust,
|
|
26
|
+
'typescript-generated' => :typescript
|
|
27
|
+
}.freeze
|
|
28
|
+
lanes = %i[init basic token oauth2 public protected failure].freeze
|
|
29
|
+
let(:matrix) { profiles }
|
|
30
|
+
|
|
31
|
+
before(:all) do
|
|
32
|
+
@server = HaveAPI::AllClientPerformanceFixture.build(resources: 2)
|
|
33
|
+
@profiles = @server.documentation_client_profiles(1)
|
|
34
|
+
@description = description(@server, 'admin')
|
|
35
|
+
@large_description = Marshal.load(Marshal.dump(@description))
|
|
36
|
+
unrelated = @large_description.fetch(:resources).fetch('resource1')
|
|
37
|
+
unrelated[:resources] = 128.times.to_h { |i| ["unrelated#{i}", Marshal.load(Marshal.dump(@description[:resources]['resource1']))] }
|
|
38
|
+
@measurements = []
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
after(:all) do
|
|
42
|
+
RSpec.configuration.reporter.message("ALL_CLIENT_PERFORMANCE #{JSON.generate(registry: profiles.keys.sort, lanes:, measurements: @measurements)}")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def headers(user)
|
|
46
|
+
{ 'HTTP_HOST' => 'example.test', 'HTTP_ACCEPT_LANGUAGE' => 'en',
|
|
47
|
+
'HTTP_AUTHORIZATION' => "Basic #{Base64.strict_encode64("#{user}:fixture-only")}" }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def description(server, user)
|
|
51
|
+
server.describe_version(HaveAPI::Context.new(server, version: 1,
|
|
52
|
+
user: HaveAPI::DocumentationPerformanceFixture::User.new(user), params: {}))
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def context(description, profile, action_name: 'protected', sample_index: 0, resource_name: 'resource0')
|
|
56
|
+
resource = description.fetch(:resources).fetch(resource_name)
|
|
57
|
+
action = resource.fetch(:actions).fetch(action_name)
|
|
58
|
+
HaveAPI::ClientExample::Context.new(version: 1, host: 'example.test',
|
|
59
|
+
base_url: 'http://example.test/benchmark/api/',
|
|
60
|
+
version_url: 'http://example.test/benchmark/api/v1/',
|
|
61
|
+
description:, profile:, resource_path: [resource_name.to_s],
|
|
62
|
+
resource:, action_name:, action:, sample: action.fetch(:examples).fetch(sample_index))
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def render(client, lane, ctx)
|
|
66
|
+
if %i[basic token oauth2].include?(lane)
|
|
67
|
+
client.render(:auth, context: ctx, auth_method: lane, auth_description: ctx.description.fetch(:authentication).fetch(lane))
|
|
68
|
+
else
|
|
69
|
+
client.render(lane == :init ? :init : :example, context: ctx)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def expect_result(result, id, lane)
|
|
74
|
+
expect(result).to be_a(Hash), "#{id}/#{lane} returned no panel"
|
|
75
|
+
if id == 'typescript-generated' && lane == :oauth2
|
|
76
|
+
expect(result[:status]).to eq(:limitation)
|
|
77
|
+
expect(result[:reason]).to be_a(String)
|
|
78
|
+
expect(result[:reason]).not_to be_empty
|
|
79
|
+
else
|
|
80
|
+
expect(result[:status]).to eq(:code), "#{id}/#{lane}: #{result.inspect}"
|
|
81
|
+
expect(result[:code]).to be_a(String)
|
|
82
|
+
expect(result[:code]).not_to be_empty
|
|
83
|
+
if %w[0 3].include?(id) && lane == :oauth2
|
|
84
|
+
expect(result[:code]).to include('OAuth2 is not supported')
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def allocations(count: 8, &block)
|
|
90
|
+
2.times(&block)
|
|
91
|
+
Array.new(3) do
|
|
92
|
+
before = GC.stat(:total_allocated_objects)
|
|
93
|
+
count.times(&block)
|
|
94
|
+
(GC.stat(:total_allocated_objects) - before) / count.to_f
|
|
95
|
+
end.min
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def calls(&block)
|
|
99
|
+
count = 0
|
|
100
|
+
trace = TracePoint.new(:call) { count += 1 }
|
|
101
|
+
trace.enable(&block)
|
|
102
|
+
count
|
|
103
|
+
ensure
|
|
104
|
+
trace&.disable
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
it 'requires exactly the reviewed registry and all real profiles' do
|
|
108
|
+
ids = described_class.clients.map(&:id)
|
|
109
|
+
expect(ids.sort).to eq(profiles.keys.sort)
|
|
110
|
+
expect(ids.uniq).to eq(ids)
|
|
111
|
+
expect(@profiles.keys.sort).to eq(%i[go rust typescript])
|
|
112
|
+
expect(@description.fetch(:authentication).keys.sort).to eq(%i[basic oauth2 token])
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
profiles.each do |id, language|
|
|
116
|
+
lanes.each do |lane|
|
|
117
|
+
it "bounds #{id}/#{lane} work and allocations with unrelated descendants" do
|
|
118
|
+
client = described_class.clients.find { |item| item.id == id }
|
|
119
|
+
expect(client).not_to be_nil
|
|
120
|
+
profile = language && @profiles.fetch(language)
|
|
121
|
+
options = { action_name: lane == :public ? 'public' : 'protected', sample_index: lane == :failure ? 1 : 0 }
|
|
122
|
+
small = context(@description, profile, **options)
|
|
123
|
+
large = context(@large_description, profile, **options)
|
|
124
|
+
expect(client.available?(small)).to be(true)
|
|
125
|
+
expect(client.available?(large)).to be(true)
|
|
126
|
+
before = Marshal.dump([small.description, large.description])
|
|
127
|
+
expected = render(client, lane, small)
|
|
128
|
+
expect_result(expected, id, lane)
|
|
129
|
+
expect(render(client, lane, large)).to eq(expected)
|
|
130
|
+
small_calls = calls { render(client, lane, small) }
|
|
131
|
+
large_calls = calls { render(client, lane, large) }
|
|
132
|
+
small_allocations = allocations { render(client, lane, small) }
|
|
133
|
+
large_allocations = allocations { render(client, lane, large) }
|
|
134
|
+
@measurements << { id:, lane:, allocations: [small_allocations, large_allocations], calls: [small_calls, large_calls] }
|
|
135
|
+
# Selected action/provider/sample is unchanged. Allow fixed VM/lookup
|
|
136
|
+
# slack, while recursive walks of the 128 unrelated subtrees must fail.
|
|
137
|
+
expect(large_calls).to be <= small_calls + 32
|
|
138
|
+
expect(large_allocations).to be <= small_allocations + 32
|
|
139
|
+
expect(Marshal.dump([small.description, large.description])).to eq(before)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def expect_panel(body, anchor, result)
|
|
145
|
+
panel = body.match(%r{<div id="#{Regexp.escape(anchor)}">\s*(.*?)</div>}m)
|
|
146
|
+
expect(panel).not_to be_nil, "Missing #{anchor}"
|
|
147
|
+
text = result.fetch(result[:status] == :code ? :code : :reason)
|
|
148
|
+
expect(panel[1]).to include(CGI.escapeHTML(text))
|
|
149
|
+
expect(panel[1]).to include(result[:status] == :code ? '<pre><code' : 'client-limitation')
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def expect_complete_page(server, user, resources)
|
|
153
|
+
desc = description(server, user)
|
|
154
|
+
response = Rack::MockRequest.new(server.app).get('/benchmark/api/v1/', **headers(user))
|
|
155
|
+
expect(response.status).to eq(200)
|
|
156
|
+
expect(response['content-type']).to start_with('text/html')
|
|
157
|
+
expect(response.body).to include('PUBLIC_SAMPLE')
|
|
158
|
+
%w[ADMIN_ONLY_ACTION ADMIN_ONLY_DEFAULT ADMIN_ONLY_REQUEST ADMIN_ONLY_RESPONSE ADMIN_ONLY_ACTION_SAMPLE admin_field admin_only].each do |marker|
|
|
159
|
+
expect(response.body.include?(marker)).to eq(user == 'admin'), marker
|
|
160
|
+
end
|
|
161
|
+
example_count = 0
|
|
162
|
+
HaveAPI::ClientExample.clients.each do |client|
|
|
163
|
+
language = matrix.fetch(client.id)
|
|
164
|
+
profile = language && server.documentation_client_profiles(1).fetch(language)
|
|
165
|
+
ctx = context(desc, profile)
|
|
166
|
+
expect(client.available?(ctx)).to be(true)
|
|
167
|
+
expect_panel(response.body, "init-#{client.id}", render(client, :init, ctx))
|
|
168
|
+
desc.fetch(:authentication).each_key do |method|
|
|
169
|
+
result = render(client, method, ctx)
|
|
170
|
+
expect_result(result, client.id, method)
|
|
171
|
+
expect_panel(response.body, "auth-#{method}-#{client.id}", result)
|
|
172
|
+
end
|
|
173
|
+
resources.times do |index|
|
|
174
|
+
resource_name = "resource#{index}"
|
|
175
|
+
desc.fetch(:resources).fetch(resource_name).fetch(:actions).each do |action_name, action|
|
|
176
|
+
action.fetch(:examples).each_index do |sample_index|
|
|
177
|
+
selected = context(desc, profile, action_name: action_name.to_s, sample_index:, resource_name:)
|
|
178
|
+
result = client.render(:example, context: selected)
|
|
179
|
+
expect_result(result, client.id, :example)
|
|
180
|
+
expect_panel(response.body, "example-#{resource_name}-#{action_name}-#{sample_index}-#{client.id}", result)
|
|
181
|
+
example_count += 1
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
expected_samples = (resources * 2 * (user == 'admin' ? 3 : 2)) + (user == 'admin' ? 1 : 0)
|
|
187
|
+
expect(example_count).to eq(expected_samples * matrix.length)
|
|
188
|
+
response
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
it 'renders every real panel and isolates admin/member/admin pages through the same adapters' do
|
|
192
|
+
first = expect_complete_page(@server, 'admin', 2)
|
|
193
|
+
expect_complete_page(@server, 'member', 2)
|
|
194
|
+
last = expect_complete_page(@server, 'admin', 2)
|
|
195
|
+
expect(last.body).to eq(first.body)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
it 'bounds complete all-profile page allocations by linear output growth' do
|
|
199
|
+
measurements = [2, 4, 8].map do |resources|
|
|
200
|
+
server = HaveAPI::AllClientPerformanceFixture.build(resources:)
|
|
201
|
+
response = expect_complete_page(server, 'admin', resources)
|
|
202
|
+
request = Rack::MockRequest.new(server.app)
|
|
203
|
+
allocated = allocations(count: 1) { request.get('/benchmark/api/v1/', **headers('admin')) }
|
|
204
|
+
{ resources:, bytes: response.body.bytesize, allocations: allocated }
|
|
205
|
+
end
|
|
206
|
+
@measurements << { pages: measurements }
|
|
207
|
+
small, medium, large = measurements
|
|
208
|
+
expect(large[:bytes]).to be > medium[:bytes]
|
|
209
|
+
expect(medium[:bytes]).to be > small[:bytes]
|
|
210
|
+
expect(medium[:allocations]).to be > small[:allocations]
|
|
211
|
+
# Doubling the added resources doubles actual panels/output. A 2.5x
|
|
212
|
+
# incremental allowance covers bookkeeping; quadratic copying approaches 4x.
|
|
213
|
+
expect(large[:allocations] - medium[:allocations]).to be <= 2.5 * (medium[:allocations] - small[:allocations])
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
# rubocop:enable RSpec/InstanceVariable, RSpec/LeakyLocalVariable
|