haveapi 0.31.0 → 0.31.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.
- checksums.yaml +4 -4
- data/benchmarks/endpoints.rb +8 -8
- data/benchmarks/performance_compare.rb +100 -14
- data/benchmarks/spec/client_examples_spec.rb +216 -0
- data/doc/client-examples.md +7 -0
- data/doc/performance.md +64 -0
- data/haveapi.gemspec +1 -1
- data/lib/haveapi/version.rb +1 -1
- data/spec/performance/event_baseline_spec.rb +29 -2
- data/spec/performance/runner_spec.rb +2 -0
- data/spec/performance/source_versions_spec.rb +168 -0
- data/test_support/all_client_performance_fixture.rb +84 -0
- metadata +6 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 88c7ed5020d8b6301015328a883c61412199cca27f7e26b934955b59715cd912
|
|
4
|
+
data.tar.gz: 04ad5b6f8090a864c7efaaed00b37ad6eb8a3291cb6009ac3056ddd40c815822
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: cf2eccf2154322f7d534bd7b8c71d7380f6c881e3a9197b511b490adb85084b9f6150a9280702b636d243a97a506cfeb4ed6db3083f4e3a15c6ef07797f91a12
|
|
7
|
+
data.tar.gz: b1e4cd6c8fc99ec02c42341e124f8c9e6b750b5c930b7a00e4eb2865f102da02f500f14e34a7992c784ff8419c3c5569690772fbfb359ec437e8a00e6d16b5d8
|
data/benchmarks/endpoints.rb
CHANGED
|
@@ -386,6 +386,7 @@ module HaveAPI
|
|
|
386
386
|
paths = %w[Gemfile flake.lock servers/ruby/Gemfile servers/ruby/haveapi.gemspec clients/ruby/Gemfile clients/ruby/haveapi-client.gemspec]
|
|
387
387
|
paths += %w[clients/rust/Gemfile clients/rust/haveapi-rust-client.gemspec]
|
|
388
388
|
paths += %w[.ruby-version flake.nix] if benchmark
|
|
389
|
+
paths |= SourceVersions::PATHS unless release
|
|
389
390
|
raw_manifests = paths.to_h do |path|
|
|
390
391
|
target = File.join(source, path)
|
|
391
392
|
raise ContractError, "Missing dependency input #{target}" unless File.file?(target)
|
|
@@ -410,7 +411,12 @@ module HaveAPI
|
|
|
410
411
|
|
|
411
412
|
result = { manifests: receipt.fetch('normalized_sha256'), locks:, release_versions: receipt }
|
|
412
413
|
else
|
|
413
|
-
|
|
414
|
+
versions = SourceVersions.capture(raw_manifests.slice(*SourceVersions::PATHS))
|
|
415
|
+
loaded = [HaveAPI::VERSION, HaveAPI::Client::VERSION, HaveAPI::RustClient::VERSION]
|
|
416
|
+
raise ContractError, 'Wrong loaded local package version' unless loaded.all? { |version| version == versions.fetch('own_version') }
|
|
417
|
+
|
|
418
|
+
result = { manifests: manifests.merge(versions.fetch('normalized_sha256')), locks:,
|
|
419
|
+
json_preparation: preparation, source_versions: versions }
|
|
414
420
|
end
|
|
415
421
|
if benchmark
|
|
416
422
|
result[:manifests] = result.fetch(:manifests).except('flake.nix', 'flake.lock')
|
|
@@ -697,13 +703,7 @@ module HaveAPI
|
|
|
697
703
|
end
|
|
698
704
|
|
|
699
705
|
inputs = result.fetch('inputs')
|
|
700
|
-
|
|
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
|
|
706
|
+
Performance.validate_input_receipts(inputs, @sources.fetch(role), profile: @options.fetch(:profile), lineage: @release_lineage)
|
|
707
707
|
expected = Identity.parse(File.read(@options.fetch(:budgets))).fetch('environment').fetch('weekly')
|
|
708
708
|
raise ContractError, 'Calibration environment mismatch' unless result.fetch('environment') == expected
|
|
709
709
|
|
|
@@ -38,7 +38,7 @@ module HaveAPI
|
|
|
38
38
|
require 'json'
|
|
39
39
|
spec = Gem::Specification.load(File.basename(ARGV.fetch(0)))
|
|
40
40
|
abort 'Cannot load gemspec' unless spec
|
|
41
|
-
puts JSON.generate(ruby: spec.required_ruby_version.to_s,
|
|
41
|
+
puts JSON.generate(name: spec.name, version: spec.version.to_s, ruby: spec.required_ruby_version.to_s,
|
|
42
42
|
dependencies: spec.dependencies.map { |d| [d.name, d.type.to_s, d.requirement.to_s] }.sort)
|
|
43
43
|
RUBY
|
|
44
44
|
environment = (ENV.keys.grep(/\ABUNDLE/) + %w[RUBYOPT RUBYLIB RUBYGEMS_GEMDEPS]).to_h { |key| [key, nil] }
|
|
@@ -209,7 +209,7 @@ module HaveAPI
|
|
|
209
209
|
end
|
|
210
210
|
|
|
211
211
|
def self.graph(inputs)
|
|
212
|
-
inputs.except('release_versions', 'json_preparation', 'contributor_flakes')
|
|
212
|
+
inputs.except('release_versions', 'source_versions', 'json_preparation', 'contributor_flakes')
|
|
213
213
|
end
|
|
214
214
|
|
|
215
215
|
def self.html(body, own)
|
|
@@ -238,6 +238,103 @@ module HaveAPI
|
|
|
238
238
|
end
|
|
239
239
|
end
|
|
240
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
|
+
|
|
241
338
|
def self.validate_source_pair(profile:, baseline_sha:, candidate_sha:)
|
|
242
339
|
raise ContractError, 'Baseline and candidate must differ' if baseline_sha == candidate_sha
|
|
243
340
|
return unless profile == 'weekly' && baseline_sha != REFERENCE
|
|
@@ -430,21 +527,10 @@ module HaveAPI
|
|
|
430
527
|
raise ContractError, 'Missing contributor flake provenance'
|
|
431
528
|
end
|
|
432
529
|
|
|
433
|
-
inputs.fetch('manifests').each do |path, digest|
|
|
434
|
-
next if Release::JSON_GEMFILES.include?(path) || lineage
|
|
435
|
-
|
|
436
|
-
raise ContractError, 'Dependency manifest escaped source' unless selected.fetch('files_sha256').fetch(path) == digest
|
|
437
|
-
end
|
|
438
530
|
elsif inputs.has_key?('contributor_flakes')
|
|
439
531
|
raise ContractError, 'Contributor flake separation without benchmark inputs'
|
|
440
532
|
end
|
|
441
|
-
|
|
442
|
-
Release.validate_inputs(inputs, selected, @samples.fetch('profile'))
|
|
443
|
-
elsif inputs.has_key?('release_versions')
|
|
444
|
-
raise ContractError, 'Unproven own-version normalization'
|
|
445
|
-
else
|
|
446
|
-
Release.validate_json_preparation(inputs, selected)
|
|
447
|
-
end
|
|
533
|
+
Performance.validate_input_receipts(inputs, selected, profile: @samples.fetch('profile'), lineage:)
|
|
448
534
|
unless observation.fetch('environment') == @samples['environment'] && Release.graph(inputs) == @samples.fetch('inputs') &&
|
|
449
535
|
observation.fetch('fixture_sha256') == @samples['fixture_sha256']
|
|
450
536
|
raise ContractError, 'Worker provenance mismatch'
|
|
@@ -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
|
data/doc/client-examples.md
CHANGED
|
@@ -247,6 +247,13 @@ They check description sharing, allocation growth and isolation between users
|
|
|
247
247
|
without a wall-clock threshold. The Rust adapter suite separately checks that
|
|
248
248
|
normalization avoids unrelated description descendants.
|
|
249
249
|
|
|
250
|
+
The additional required `make test-performance-clients` contract checks every
|
|
251
|
+
registered renderer and mode with real generated profiles, bounded per-render
|
|
252
|
+
work and allocations, and complete-page allocation scaling. It runs in the
|
|
253
|
+
locked, unbundled benchmark environment, separately from the ordinary server
|
|
254
|
+
bundle. See [performance testing](performance.md#all-client-documentation-contract)
|
|
255
|
+
for preparation, exact coverage and CI behavior.
|
|
256
|
+
|
|
250
257
|
### Documentation benchmark
|
|
251
258
|
|
|
252
259
|
Prepare the server bundle in its declared Nix environment, then run the opt-in
|
data/doc/performance.md
CHANGED
|
@@ -13,6 +13,56 @@ query behavior; they do not measure production database, network server or
|
|
|
13
13
|
vpsAdmin latency. The checks detect specific regressions and do not guarantee
|
|
14
14
|
every future performance characteristic.
|
|
15
15
|
|
|
16
|
+
## All-client documentation contract
|
|
17
|
+
|
|
18
|
+
The required performance workflow first runs `make test-performance-clients`
|
|
19
|
+
against the selected candidate in the prepared, unbundled benchmark shell:
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
export HAVEAPI_BENCHMARK_GEM_HOME="$PWD/servers/ruby/benchmarks/.gems"
|
|
23
|
+
nix-shell servers/ruby/benchmarks/shell.nix --run 'make test-performance-clients'
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Prepare the frozen benchmark bundle as described below before running this
|
|
27
|
+
command. It loads the actual Go, Rust and TypeScript generator sources and fails
|
|
28
|
+
if a dependency or profile is missing. It needs no Go, Rust or TypeScript
|
|
29
|
+
compiler. The ordinary `make test-performance` remains a separate server-bundle
|
|
30
|
+
check; both checks are required.
|
|
31
|
+
|
|
32
|
+
The explicit coverage matrix must equal the live renderer registry. Its 15
|
|
33
|
+
entries include every callback, async, blocking and generated variant. Each
|
|
34
|
+
entry renders initialization, Basic, token and OAuth2 authentication, public and
|
|
35
|
+
protected actions, and a failed action sample. Basic authentication and action
|
|
36
|
+
samples must produce code for every renderer. Generated TypeScript's OAuth2
|
|
37
|
+
limitation and the Ruby library/CLI's existing unsupported-OAuth2 comments have
|
|
38
|
+
explicit expectations. New renderer IDs or providers require reviewed coverage;
|
|
39
|
+
they cannot silently disappear from measurement.
|
|
40
|
+
|
|
41
|
+
Direct checks hold the selected action, sample and provider constant while adding
|
|
42
|
+
128 unrelated resource subtrees. Setup and context ownership copying happen
|
|
43
|
+
outside measurement. Three batches of eight renders check allocations, and a
|
|
44
|
+
separate Ruby-call count checks traversal growth; each allows 32 additional
|
|
45
|
+
objects or calls per render. Output must remain identical and inputs unchanged.
|
|
46
|
+
|
|
47
|
+
Complete Rack HTML requests enable all three generated profiles. Checks compare
|
|
48
|
+
every registered initialization/authentication/action panel with real renderer
|
|
49
|
+
output, including successful and failed samples, and exercise admin/member/admin
|
|
50
|
+
isolation through the same adapters. Allocation measurements use pages with
|
|
51
|
+
2, 4 and 8 resources, each with two actions and three admin-visible samples per
|
|
52
|
+
action, plus one admin-only action. Member pages hide the admin sample and action.
|
|
53
|
+
The added-resource allocation increment may grow by at most 2.5 times when the
|
|
54
|
+
resource increment doubles. This permits linear panel/output growth while
|
|
55
|
+
rejecting the measured whole-description-per-example copy regression. The
|
|
56
|
+
fixture uses scalar fields so Go action panels exercise supported code paths.
|
|
57
|
+
|
|
58
|
+
The log retains the named registry, kinds, allocations, Ruby-call counts and
|
|
59
|
+
page byte counts. These deterministic checks measure the selected candidate.
|
|
60
|
+
They use no wall-clock threshold and provide no historical timing result. The
|
|
61
|
+
paired timing suite below retains its original workloads, fixed reference and budgets; its
|
|
62
|
+
reference predates generated TypeScript support. Relevant pushes and pull
|
|
63
|
+
requests, manual runs and scheduled runs must pass the all-client contract before
|
|
64
|
+
the paired comparison. Changes under any client directory trigger the gate.
|
|
65
|
+
|
|
16
66
|
## Local commands
|
|
17
67
|
|
|
18
68
|
The benchmark has its own third-party tool bundle and pinned Nix shell in
|
|
@@ -200,6 +250,20 @@ the original manifest contents, hashes and declaration evidence, then compare
|
|
|
200
250
|
normalized contents. Replay verifies the transformation from the retained raw
|
|
201
251
|
inputs. Other dependency requirements and manifest contents must match.
|
|
202
252
|
|
|
253
|
+
Comparisons outside the historical 0.30 maintenance mapping retain a
|
|
254
|
+
`source_versions` receipt for each source. It validates the numeric source
|
|
255
|
+
`VERSION`, matching Ruby server/client
|
|
256
|
+
and Rust generator version constants, package identities and exact synchronized
|
|
257
|
+
internal `haveapi-client` requirements before normalizing those version fields.
|
|
258
|
+
Preparation, live workers and replay bind the raw hashes to the verified source
|
|
259
|
+
and compare the normalized manifests. Missing, duplicate, inconsistent or tampered
|
|
260
|
+
metadata fails. All other manifest bytes, external dependencies, runtime and
|
|
261
|
+
benchmark locks remain part of the strict comparison. Older raw-manifest samples
|
|
262
|
+
still replay; removing a receipt from normalized samples fails source validation.
|
|
263
|
+
This rule permits coordinated package-version changes without changing baseline
|
|
264
|
+
selection or the separate historical 0.30 maintenance-lineage proof. Response
|
|
265
|
+
changes remain diagnostics under the existing per-revision stability policy.
|
|
266
|
+
|
|
203
267
|
## GitHub workflow and baseline selection
|
|
204
268
|
|
|
205
269
|
The `servers/ruby performance` workflow runs on pushes, ordinary pull requests,
|
data/haveapi.gemspec
CHANGED
|
@@ -15,7 +15,7 @@ Gem::Specification.new do |s|
|
|
|
15
15
|
s.required_ruby_version = ">= #{File.read('../../.ruby-version').strip}"
|
|
16
16
|
|
|
17
17
|
s.add_dependency 'activesupport', '>= 7.1'
|
|
18
|
-
s.add_dependency 'haveapi-client', '~> 0.31.
|
|
18
|
+
s.add_dependency 'haveapi-client', '~> 0.31.2'
|
|
19
19
|
s.add_dependency 'i18n', '>= 1.6', '< 2'
|
|
20
20
|
s.add_dependency 'json'
|
|
21
21
|
s.add_dependency 'mail'
|
data/lib/haveapi/version.rb
CHANGED
|
@@ -269,7 +269,7 @@ RSpec.describe HaveAPI::PerformanceCI do
|
|
|
269
269
|
end
|
|
270
270
|
|
|
271
271
|
it 'skips only irrelevant valid push/PR changes and still forces scheduled/manual work' do
|
|
272
|
-
allow(repository).to receive(:paths).and_return(['
|
|
272
|
+
allow(repository).to receive(:paths).and_return(['LICENSE.txt'])
|
|
273
273
|
event.merge!('ref' => 'refs/heads/master', 'after' => candidate, 'before' => base)
|
|
274
274
|
expect(resolve('push', event)).to include(relevant: false)
|
|
275
275
|
expect(resolve('schedule', event)).to include(relevant: true)
|
|
@@ -280,11 +280,38 @@ RSpec.describe HaveAPI::PerformanceCI do
|
|
|
280
280
|
%w[flake.lock Gemfile Makefile .ruby-version servers/ruby/benchmarks/endpoints.rb
|
|
281
281
|
servers/ruby/benchmarks/Gemfile servers/ruby/benchmarks/Gemfile.lock servers/ruby/benchmarks/shell.nix
|
|
282
282
|
servers/ruby/test_support/performance_sqlite_fixture.rb clients/ruby/Gemfile clients/rust/Gemfile
|
|
283
|
-
doc/performance.md utils/performance_ci.rb .github/workflows/servers-ruby-performance.yml
|
|
283
|
+
doc/performance.md utils/performance_ci.rb .github/workflows/servers-ruby-performance.yml
|
|
284
|
+
servers/ruby/benchmarks/spec/client_examples_spec.rb servers/ruby/test_support/all_client_performance_fixture.rb
|
|
285
|
+
clients/go/lib/haveapi/go_client/documentation.rb clients/typescript/lib/haveapi/typescript_client/documentation.rb
|
|
286
|
+
clients/js/src/client.ts clients/python/haveapi/client.py clients/php/src/Client.php clients/new-client/renderer.rb].each do |path|
|
|
284
287
|
expect(described_class::RELEVANT).to match(path)
|
|
285
288
|
end
|
|
286
289
|
end
|
|
287
290
|
|
|
291
|
+
it 'requires the current-client contract before comparison and rejects a missing contract outcome' do
|
|
292
|
+
path = File.expand_path('../../../../.github/workflows/servers-ruby-performance.yml', __dir__)
|
|
293
|
+
steps = YAML.load_file(path).fetch('jobs').fetch('performance').fetch('steps')
|
|
294
|
+
contract = steps.find { |step| step['id'] == 'clients' }
|
|
295
|
+
comparison = steps.find { |step| step['name'] == 'Run the selected pair outside Bundler' }
|
|
296
|
+
expect(steps.index(contract)).to be < steps.index(comparison)
|
|
297
|
+
expect(contract.fetch('if')).to eq(comparison.fetch('if'))
|
|
298
|
+
expect(contract.fetch('run')).to include('make test-performance-clients', "echo 'outcome=passed'")
|
|
299
|
+
completion = steps.find { |step| step['id'] == 'completion' }
|
|
300
|
+
Dir.mktmpdir('haveapi-client-contract-gate-') do |directory|
|
|
301
|
+
File.write(File.join(directory, 'selection.json'), '{}')
|
|
302
|
+
File.write(File.join(directory, 'runner.exit'), "0\n")
|
|
303
|
+
FileUtils.mkdir_p(File.join(directory, 'results'))
|
|
304
|
+
File.write(File.join(directory, 'results/report.json'), '{}')
|
|
305
|
+
File.write(File.join(directory, 'all-client-contract.log'), 'passed')
|
|
306
|
+
[nil, '', 'failed', 'passed'].each do |outcome|
|
|
307
|
+
environment = { 'RELEVANT' => 'true', 'CLIENT_CONTRACT' => outcome,
|
|
308
|
+
'PERFORMANCE_EVIDENCE' => directory, 'GITHUB_OUTPUT' => File.join(directory, 'output') }
|
|
309
|
+
_output, _error, status = Open3.capture3(environment, 'bash', '-euo', 'pipefail', '-c', completion.fetch('run'))
|
|
310
|
+
expect(status.success?).to eq(outcome == 'passed')
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
|
|
288
315
|
it 'only accepts successful job completion in the aggregate gate' do
|
|
289
316
|
expect(described_class.aggregate!('success', 'compared')).to be(true)
|
|
290
317
|
expect(described_class.aggregate!('success', 'no-work')).to be(true)
|
|
@@ -4,6 +4,8 @@ require 'tmpdir'
|
|
|
4
4
|
require 'fileutils'
|
|
5
5
|
require_relative '../../benchmarks/endpoints'
|
|
6
6
|
require_relative '../../../../utils/performance_ci'
|
|
7
|
+
require_relative '../../../../clients/ruby/lib/haveapi/client/version'
|
|
8
|
+
require_relative '../../../../clients/rust/lib/haveapi/rust_client/version'
|
|
7
9
|
require_relative 'support/samples'
|
|
8
10
|
require_relative '../../test_support/performance_endpoint_fixture'
|
|
9
11
|
require_relative '../../test_support/documentation_performance_fixture'
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
require 'tmpdir'
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require_relative '../../../../utils/performance_ci'
|
|
5
|
+
require_relative '../../benchmarks/endpoints'
|
|
6
|
+
require_relative '../../../../clients/ruby/lib/haveapi/client/version'
|
|
7
|
+
require_relative '../../../../clients/rust/lib/haveapi/rust_client/version'
|
|
8
|
+
require_relative 'support/samples'
|
|
9
|
+
|
|
10
|
+
describe HaveAPI::PerformanceCI::Campaign do
|
|
11
|
+
let(:root) { File.expand_path('../../../..', __dir__) }
|
|
12
|
+
let(:error) { HaveAPI::Performance::ContractError }
|
|
13
|
+
|
|
14
|
+
before do
|
|
15
|
+
# These metadata fixtures evaluate real gemspecs without loading the optional
|
|
16
|
+
# Rust adapter. Ordinary server bundles do not include its dependencies; the
|
|
17
|
+
# real benchmark environment checks their installed identities separately.
|
|
18
|
+
allow(Gem::Specification).to receive(:find_by_name).and_call_original
|
|
19
|
+
{ 'fiddle' => '1.1.8', 'tomlrb' => '2.0.5' }.each do |name, version|
|
|
20
|
+
specification = Gem::Specification.new do |spec|
|
|
21
|
+
spec.name = name
|
|
22
|
+
spec.version = version
|
|
23
|
+
end
|
|
24
|
+
allow(Gem::Specification).to receive(:find_by_name).with(name).and_return(specification)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def source_contents(version)
|
|
29
|
+
paths = HaveAPI::Performance::Release.paths('quick')
|
|
30
|
+
actual = File.read(File.join(root, 'VERSION')).strip
|
|
31
|
+
paths.to_h do |path|
|
|
32
|
+
text = File.read(File.join(root, path))
|
|
33
|
+
text = "#{version}\n" if path == 'VERSION'
|
|
34
|
+
text = text.sub("VERSION = '#{actual}'", "VERSION = '#{version}'") if HaveAPI::Performance::Release::VERSIONS.has_key?(path)
|
|
35
|
+
text = text.sub("'haveapi-client', '~> #{actual}'", "'haveapi-client', '~> #{version}'") if path.end_with?('.gemspec')
|
|
36
|
+
[path, text]
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def write_source(directory, contents)
|
|
41
|
+
contents.each do |path, text|
|
|
42
|
+
FileUtils.mkdir_p(File.dirname(File.join(directory, path)))
|
|
43
|
+
File.write(File.join(directory, path), text)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def prepare(baseline, candidate)
|
|
48
|
+
Dir.mktmpdir('haveapi-own-versions-') do |directory|
|
|
49
|
+
roots = { baseline: File.join(directory, 'baseline'), candidate: File.join(directory, 'candidate') }
|
|
50
|
+
write_source(roots[:baseline], baseline)
|
|
51
|
+
write_source(roots[:candidate], candidate)
|
|
52
|
+
campaign = described_class.new(repository: nil, selection: { 'profile' => 'quick' }, evidence: directory, sources: directory)
|
|
53
|
+
campaign.send(:verify_dependencies, roots)
|
|
54
|
+
yield roots if block_given?
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
%w[0.30.0 0.31.0].each do |baseline|
|
|
59
|
+
it "accepts synchronized #{baseline} to 0.31.1 metadata during preparation" do
|
|
60
|
+
expect { prepare(source_contents(baseline), source_contents('0.31.1')) }.not_to raise_error
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
it "retains and validates the live worker graph for #{baseline} to 0.31.1" do
|
|
64
|
+
prepare(source_contents(baseline), source_contents('0.31.1')) do |roots|
|
|
65
|
+
graphs = roots.map do |role, directory|
|
|
66
|
+
version = role == :baseline ? baseline : '0.31.1'
|
|
67
|
+
%w[HaveAPI::VERSION HaveAPI::Client::VERSION HaveAPI::RustClient::VERSION].each { |constant| stub_const(constant, version) }
|
|
68
|
+
inputs = JSON.parse(JSON.generate(HaveAPI::Performance::Worker.inputs(directory, 'quick', benchmark: true)))
|
|
69
|
+
hashes = source_contents(version).transform_values { |text| Digest::SHA256.hexdigest(text) }
|
|
70
|
+
expect do
|
|
71
|
+
HaveAPI::Performance.validate_input_receipts(inputs, { 'files_sha256' => hashes }, profile: 'quick', lineage: nil)
|
|
72
|
+
end.not_to raise_error
|
|
73
|
+
HaveAPI::Performance::Release.graph(inputs)
|
|
74
|
+
end
|
|
75
|
+
expect(graphs.first).to eq(graphs.last)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
it "replays the synchronized #{baseline} to 0.31.1 graph" do
|
|
80
|
+
samples, budgets = replay_samples(baseline)
|
|
81
|
+
expect(compare(samples, budgets).fetch('status')).to eq('pass')
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def replay_samples(baseline = '0.31.0')
|
|
86
|
+
samples, budgets = PerformanceSamples.fixture
|
|
87
|
+
samples.fetch('sources').each do |role, source|
|
|
88
|
+
contents = source_contents(role == 'baseline' ? baseline : '0.31.1').slice(*HaveAPI::Performance::SourceVersions::PATHS)
|
|
89
|
+
receipt = HaveAPI::Performance::SourceVersions.capture(contents)
|
|
90
|
+
source['files_sha256'].merge!(receipt.fetch('raw_sha256'))
|
|
91
|
+
samples.fetch('observations').each_value do |observation|
|
|
92
|
+
next unless observation.fetch('source').fetch('root') == source.fetch('root')
|
|
93
|
+
|
|
94
|
+
inputs = Marshal.load(Marshal.dump(observation.fetch('inputs')))
|
|
95
|
+
inputs['manifests'].merge!(receipt.fetch('normalized_sha256'))
|
|
96
|
+
inputs['source_versions'] = receipt
|
|
97
|
+
observation['inputs'] = inputs
|
|
98
|
+
samples['inputs'] = HaveAPI::Performance::Release.graph(inputs)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
[samples, JSON.parse(budgets)]
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def compare(samples, budgets)
|
|
105
|
+
HaveAPI::Performance::Comparator.new(samples, budgets, budget_sha256: samples.fetch('budget_sha256')).compare
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
it 'rejects external dependency and other unnormalized input edits in preparation' do
|
|
109
|
+
%w[Gemfile .ruby-version servers/ruby/haveapi.gemspec].each do |path|
|
|
110
|
+
changed = source_contents('0.31.1')
|
|
111
|
+
changed[path] = path == '.ruby-version' ? "3.0.0\n" : "#{changed[path]}\n# unrelated change\n"
|
|
112
|
+
expect { prepare(source_contents('0.31.0'), changed) }.to raise_error(HaveAPI::PerformanceCI::Error, /Dependency inputs differ/)
|
|
113
|
+
end
|
|
114
|
+
changed = source_contents('0.31.1')
|
|
115
|
+
changed['servers/ruby/haveapi.gemspec'] = changed['servers/ruby/haveapi.gemspec'].sub("'activesupport', '>= 7.1'", "'activesupport', '>= 999.0'")
|
|
116
|
+
expect { prepare(source_contents('0.31.0'), changed) }.to raise_error(error, /Benchmark dependencies do not satisfy/)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
it 'rejects malformed, missing, duplicate and unsynchronized own declarations' do
|
|
120
|
+
original = source_contents('0.31.1').slice(*HaveAPI::Performance::SourceVersions::PATHS)
|
|
121
|
+
changes = [
|
|
122
|
+
['VERSION', "0.31.01\n"], ['VERSION', " 0.31.1\n"],
|
|
123
|
+
['servers/ruby/lib/haveapi/version.rb', original['servers/ruby/lib/haveapi/version.rb'].sub('0.31.1', '0.31.0')],
|
|
124
|
+
['servers/ruby/lib/haveapi/version.rb', "VERSION = '0.31.1'.freeze\nVERSION = '0.31.1'.freeze\n"],
|
|
125
|
+
['servers/ruby/haveapi.gemspec', original['servers/ruby/haveapi.gemspec'].sub('~> 0.31.1', '~> 0.31.0')],
|
|
126
|
+
['servers/ruby/haveapi.gemspec', original['servers/ruby/haveapi.gemspec'].sub("~> 0.31.1'", "~> 0.31.1', '< 0.32'")],
|
|
127
|
+
['servers/ruby/haveapi.gemspec', "#{original['servers/ruby/haveapi.gemspec']}s.add_dependency 'haveapi-client', '~> 0.31.1'\n"],
|
|
128
|
+
['servers/ruby/haveapi.gemspec', original['servers/ruby/haveapi.gemspec'].sub('HaveAPI::VERSION', "'0.31.0'")],
|
|
129
|
+
['clients/ruby/haveapi-client.gemspec', original['clients/ruby/haveapi-client.gemspec'].sub("'haveapi-client'", "'other-client'")]
|
|
130
|
+
]
|
|
131
|
+
changes.each do |path, text|
|
|
132
|
+
expect { HaveAPI::Performance::SourceVersions.capture(original.merge(path => text)) }.to raise_error(error)
|
|
133
|
+
end
|
|
134
|
+
expect { HaveAPI::Performance::SourceVersions.capture(original.except('VERSION')) }.to raise_error(error)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
it 'rejects loaded versions that do not match the captured source metadata' do
|
|
138
|
+
Dir.mktmpdir('haveapi-own-worker-') do |directory|
|
|
139
|
+
write_source(directory, source_contents('0.31.99'))
|
|
140
|
+
expect { HaveAPI::Performance::Worker.inputs(directory, 'quick', benchmark: true) }.to raise_error(error, /Wrong loaded local package version/)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
it 'rejects tampered, stripped, conflicting or unbound receipts and changed runtime/lock inputs on replay' do
|
|
145
|
+
%w[raw normalized content stripped conflicting source runtime lock].each do |fault|
|
|
146
|
+
samples, budgets = replay_samples
|
|
147
|
+
observation = samples.fetch('observations').fetch('candidate')
|
|
148
|
+
inputs = observation.fetch('inputs')
|
|
149
|
+
receipt = inputs.fetch('source_versions')
|
|
150
|
+
case fault
|
|
151
|
+
when 'raw' then receipt['raw_sha256']['VERSION'] = 'f' * 64
|
|
152
|
+
when 'normalized' then receipt['normalized_sha256']['VERSION'] = 'f' * 64
|
|
153
|
+
when 'content' then receipt['contents']['VERSION'] = "0.31.2\n"
|
|
154
|
+
when 'stripped' then inputs.delete('source_versions')
|
|
155
|
+
when 'conflicting' then inputs['release_versions'] = {}
|
|
156
|
+
when 'source' then samples['sources']['candidate']['files_sha256']['VERSION'] = 'f' * 64
|
|
157
|
+
when 'runtime' then observation['environment'] = { 'ruby' => 'changed' }
|
|
158
|
+
when 'lock' then inputs['locks']['Gemfile.lock'] = 'f' * 64
|
|
159
|
+
end
|
|
160
|
+
expect { compare(samples, budgets) }.to raise_error(error), fault
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
it 'continues to replay legacy raw manifests without own-version receipts' do
|
|
165
|
+
samples, budgets = PerformanceSamples.fixture
|
|
166
|
+
expect(compare(samples, JSON.parse(budgets)).fetch('status')).to eq('pass')
|
|
167
|
+
end
|
|
168
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
require_relative 'documentation_performance_fixture'
|
|
2
|
+
|
|
3
|
+
module HaveAPI
|
|
4
|
+
# Scalar samples deliberately stay inside every generator's supported subset.
|
|
5
|
+
# The historical timing fixture and its calibrated workloads stay separate.
|
|
6
|
+
module AllClientPerformanceFixture
|
|
7
|
+
class TokenConfig < Authentication::Token::Config
|
|
8
|
+
# Keep the saved JSON SDK description and live provider value identical.
|
|
9
|
+
query_parameter '_auth_token'
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
class OAuthConfig < Authentication::OAuth2::Config
|
|
13
|
+
def base_url
|
|
14
|
+
'http://example.test'
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.build(resources:)
|
|
19
|
+
definitions = Module.new
|
|
20
|
+
resources.times do |index|
|
|
21
|
+
resource = Class.new(HaveAPI::Resource)
|
|
22
|
+
definitions.const_set("Resource#{index}", resource)
|
|
23
|
+
resource.resource_name = "Resource#{index}"
|
|
24
|
+
resource.version 1
|
|
25
|
+
resource.route "resources#{index}"
|
|
26
|
+
%w[Public Protected].each { |name| add_action(resource, name) }
|
|
27
|
+
DocumentationPerformanceFixture.send(:add_admin_action, resource) if index == 0
|
|
28
|
+
end
|
|
29
|
+
server = HaveAPI::Server.new(definitions)
|
|
30
|
+
server.use_version(1)
|
|
31
|
+
server.default_version = 1
|
|
32
|
+
server.auth_chain << DocumentationPerformanceFixture::BasicProvider
|
|
33
|
+
server.auth_chain << Authentication::Token.with_config(TokenConfig)
|
|
34
|
+
server.auth_chain << Authentication::OAuth2.with_config(OAuthConfig)
|
|
35
|
+
server.documentation_clients(1,
|
|
36
|
+
go: { import_path: 'example.test/performance/client' },
|
|
37
|
+
rust: { crate_name: 'performance_client' },
|
|
38
|
+
typescript: { package_name: 'performance-client' })
|
|
39
|
+
server.mount('/benchmark/api/')
|
|
40
|
+
server
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.add_action(resource, name)
|
|
44
|
+
resource.define_action(name) do
|
|
45
|
+
route name.downcase
|
|
46
|
+
http_method :post
|
|
47
|
+
auth name == 'Protected'
|
|
48
|
+
input(:hash) do
|
|
49
|
+
string :value
|
|
50
|
+
bool :enabled
|
|
51
|
+
integer :count
|
|
52
|
+
string :admin_field, default: 'ADMIN_ONLY_DEFAULT'
|
|
53
|
+
end
|
|
54
|
+
output(:hash) do
|
|
55
|
+
string :marker
|
|
56
|
+
string :admin_field
|
|
57
|
+
end
|
|
58
|
+
authorize do |user|
|
|
59
|
+
if user && user.login != 'admin'
|
|
60
|
+
input blacklist: [:admin_field]
|
|
61
|
+
output blacklist: [:admin_field]
|
|
62
|
+
end
|
|
63
|
+
allow
|
|
64
|
+
end
|
|
65
|
+
example 'Success' do
|
|
66
|
+
request(value: '', enabled: false, count: 0)
|
|
67
|
+
response(marker: 'PUBLIC_SAMPLE')
|
|
68
|
+
end
|
|
69
|
+
example 'Failure' do
|
|
70
|
+
request(value: '', enabled: false, count: 0)
|
|
71
|
+
status false
|
|
72
|
+
message 'Expected failure'
|
|
73
|
+
errors(value: ['fixture error'])
|
|
74
|
+
end
|
|
75
|
+
example 'Admin' do
|
|
76
|
+
authorize { |user| user.login == 'admin' }
|
|
77
|
+
request(admin_field: 'ADMIN_ONLY_REQUEST')
|
|
78
|
+
response(admin_field: 'ADMIN_ONLY_RESPONSE')
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
private_class_method :add_action
|
|
83
|
+
end
|
|
84
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: haveapi
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.31.
|
|
4
|
+
version: 0.31.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jakub Skokan
|
|
@@ -29,14 +29,14 @@ dependencies:
|
|
|
29
29
|
requirements:
|
|
30
30
|
- - "~>"
|
|
31
31
|
- !ruby/object:Gem::Version
|
|
32
|
-
version: 0.31.
|
|
32
|
+
version: 0.31.2
|
|
33
33
|
type: :runtime
|
|
34
34
|
prerelease: false
|
|
35
35
|
version_requirements: !ruby/object:Gem::Requirement
|
|
36
36
|
requirements:
|
|
37
37
|
- - "~>"
|
|
38
38
|
- !ruby/object:Gem::Version
|
|
39
|
-
version: 0.31.
|
|
39
|
+
version: 0.31.2
|
|
40
40
|
- !ruby/object:Gem::Dependency
|
|
41
41
|
name: i18n
|
|
42
42
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -217,6 +217,7 @@ files:
|
|
|
217
217
|
- benchmarks/performance_budgets.json
|
|
218
218
|
- benchmarks/performance_compare.rb
|
|
219
219
|
- benchmarks/shell.nix
|
|
220
|
+
- benchmarks/spec/client_examples_spec.rb
|
|
220
221
|
- doc/client-examples.md
|
|
221
222
|
- doc/create-client.md
|
|
222
223
|
- doc/hooks.erb
|
|
@@ -396,6 +397,7 @@ files:
|
|
|
396
397
|
- spec/performance/json_activation_spec.rb
|
|
397
398
|
- spec/performance/release_lineage_spec.rb
|
|
398
399
|
- spec/performance/runner_spec.rb
|
|
400
|
+
- spec/performance/source_versions_spec.rb
|
|
399
401
|
- spec/performance/sqlite_spec.rb
|
|
400
402
|
- spec/performance/support/live_worker.rb
|
|
401
403
|
- spec/performance/support/samples.rb
|
|
@@ -412,6 +414,7 @@ files:
|
|
|
412
414
|
- spec/validators/length_spec.rb
|
|
413
415
|
- spec/validators/numericality_spec.rb
|
|
414
416
|
- spec/validators/presence_spec.rb
|
|
417
|
+
- test_support/all_client_performance_fixture.rb
|
|
415
418
|
- test_support/client_test_api.rb
|
|
416
419
|
- test_support/client_test_server.rb
|
|
417
420
|
- test_support/documentation_performance_fixture.rb
|