haveapi 0.30.1 → 0.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.gitignore +1 -0
- data/Gemfile +1 -0
- data/benchmarks/Gemfile +30 -0
- data/benchmarks/Gemfile.lock +296 -0
- data/benchmarks/documentation.rb +277 -0
- data/benchmarks/endpoints.rb +786 -0
- data/benchmarks/performance_budgets.json +1354 -0
- data/benchmarks/performance_compare.rb +678 -0
- data/benchmarks/shell.nix +21 -0
- data/doc/client-examples.md +179 -26
- data/doc/performance.md +310 -0
- 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/documentation_context.rb +42 -12
- data/lib/haveapi/version.rb +1 -1
- data/lib/haveapi/views/version_page.erb +2 -2
- data/spec/client_examples/context_performance_spec.rb +72 -0
- data/spec/client_examples/context_spec.rb +206 -0
- 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/documentation/performance_spec.rb +79 -0
- data/spec/performance/comparator_spec.rb +472 -0
- data/spec/performance/endpoint_contract_spec.rb +226 -0
- data/spec/performance/endpoint_scaling_spec.rb +82 -0
- data/spec/performance/event_baseline_spec.rb +407 -0
- data/spec/performance/json_activation_spec.rb +151 -0
- data/spec/performance/release_lineage_spec.rb +470 -0
- data/spec/performance/runner_spec.rb +610 -0
- data/spec/performance/sqlite_spec.rb +194 -0
- data/spec/performance/support/live_worker.rb +44 -0
- data/spec/performance/support/samples.rb +72 -0
- data/test_support/documentation_performance_fixture.rb +106 -0
- data/test_support/performance_endpoint_fixture.rb +350 -0
- data/test_support/performance_sqlite_fixture.rb +384 -0
- metadata +29 -3
|
@@ -0,0 +1,610 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
require 'open3'
|
|
3
|
+
require 'tmpdir'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require_relative '../../benchmarks/endpoints'
|
|
6
|
+
require_relative '../../../../utils/performance_ci'
|
|
7
|
+
require_relative 'support/samples'
|
|
8
|
+
require_relative '../../test_support/performance_endpoint_fixture'
|
|
9
|
+
require_relative '../../test_support/documentation_performance_fixture'
|
|
10
|
+
|
|
11
|
+
describe HaveAPI::Performance::Worker do
|
|
12
|
+
def live_worker(control, rack_env: 'development')
|
|
13
|
+
root = File.expand_path('../../../..', __dir__)
|
|
14
|
+
sqlite = %w[query sqlite-clean].include?(control)
|
|
15
|
+
spec = { 'id' => sqlite ? 'sqlite.page' : 'api.public_get',
|
|
16
|
+
'dimensions' => sqlite ? { 'rows' => 128, 'page_size' => 32 } : {} }
|
|
17
|
+
request = { source_root: root, source_sha: HaveAPI::Performance::REFERENCE, profile: 'quick',
|
|
18
|
+
counts: { HaveAPI::Performance::Identity.key(spec) => 3 } }
|
|
19
|
+
environment = ENV.keys.grep(/\ABUNDLE|\ABUNDLER|\ARUBY(?:OPT|LIB)\z/).to_h { |key| [key, nil] }
|
|
20
|
+
environment['RACK_ENV'] = rack_env
|
|
21
|
+
Dir.mktmpdir('haveapi-worker-control-') do |harness|
|
|
22
|
+
paths = %w[benchmarks/endpoints.rb benchmarks/performance_compare.rb spec/performance/support/live_worker.rb Gemfile.lock]
|
|
23
|
+
paths += HaveAPI::Performance::FIXTURE_PATHS.map { |name| "test_support/#{name}" }
|
|
24
|
+
paths.each do |path|
|
|
25
|
+
destination = File.join(harness, 'servers/ruby', path)
|
|
26
|
+
FileUtils.mkdir_p(File.dirname(destination))
|
|
27
|
+
FileUtils.cp(File.join(root, 'servers/ruby', path), destination)
|
|
28
|
+
end
|
|
29
|
+
# Ordinary server CI resolves only its component bundle. Prove these
|
|
30
|
+
# isolated controls also run with no root tooling Gemfile.lock present.
|
|
31
|
+
expect(File.exist?(File.join(harness, 'Gemfile.lock'))).to be(false)
|
|
32
|
+
output, errors, status = Open3.capture3(environment, RbConfig.ruby, File.join(harness, 'servers/ruby/spec/performance/support/live_worker.rb'),
|
|
33
|
+
control, stdin_data: JSON.generate(request))
|
|
34
|
+
[status.exitstatus, status.success? ? JSON.parse(output) : errors]
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
it 'measures real isolated requests and detects live allocation amplification' do
|
|
39
|
+
clean_code, clean = live_worker('clean')
|
|
40
|
+
fault_code, fault = live_worker('allocation')
|
|
41
|
+
expect(clean_code).to eq(0), clean.inspect
|
|
42
|
+
expect(fault_code).to eq(0), fault.inspect
|
|
43
|
+
root = File.expand_path('../../../..', __dir__)
|
|
44
|
+
expect(clean['inputs']['manifests']).to eq('servers/ruby/Gemfile' => Digest::SHA256.file(File.join(root, 'servers/ruby/Gemfile')).hexdigest)
|
|
45
|
+
expect(clean['inputs']['locks']).to eq('servers/ruby/Gemfile.lock' => Digest::SHA256.file(File.join(root, 'servers/ruby/Gemfile.lock')).hexdigest)
|
|
46
|
+
expect(clean['cases'].first['outputs'].length).to eq(1)
|
|
47
|
+
expect(clean['cases'].first['outputs'].first['count']).to eq(3)
|
|
48
|
+
expect(fault['cases'].first['allocated_objects']).to be > clean['cases'].first['allocated_objects'] + 4000
|
|
49
|
+
expect(fault['source']['loaded_sources'].keys).to include('servers/ruby/lib/haveapi/server.rb',
|
|
50
|
+
'servers/ruby/lib/haveapi/model_adapters/active_record.rb',
|
|
51
|
+
'clients/ruby/lib/haveapi/client/client.rb')
|
|
52
|
+
# Replay the live worker's validated compact summary through the real
|
|
53
|
+
# comparator CLI. Timing traces are controlled here; the allocation fault is
|
|
54
|
+
# measured in the isolated process rather than manufactured arithmetic.
|
|
55
|
+
samples, budget_json = PerformanceSamples.fixture
|
|
56
|
+
budget = JSON.parse(budget_json)
|
|
57
|
+
baseline = clean['cases'].first.merge('observation' => 'baseline', 'wall_seconds' => 0.001, 'cpu_seconds' => 0.001)
|
|
58
|
+
candidate = fault['cases'].first.merge('observation' => 'candidate', 'wall_seconds' => 0.001, 'cpu_seconds' => 0.001)
|
|
59
|
+
entry = samples['cases'].first
|
|
60
|
+
entry['iterations'] = 3
|
|
61
|
+
entry['self_pairs'].each do |pair|
|
|
62
|
+
name = pair['workload_order'] == 'forward' ? 'baseline' : 'baseline-reverse'
|
|
63
|
+
pair.merge!('baseline' => baseline.merge('observation' => name), 'candidate' => baseline.merge('observation' => name))
|
|
64
|
+
end
|
|
65
|
+
entry['pairs'].each do |pair|
|
|
66
|
+
suffix = pair['workload_order'] == 'forward' ? '' : '-reverse'
|
|
67
|
+
pair.merge!('baseline' => baseline.merge('observation' => "baseline#{suffix}"),
|
|
68
|
+
'candidate' => candidate.merge('observation' => "candidate#{suffix}"))
|
|
69
|
+
end
|
|
70
|
+
reference = baseline['allocated_objects']
|
|
71
|
+
budget['profiles']['quick'].first.merge!('reference_allocations' => reference, 'allocation_ceiling' => (reference * 1.25).ceil + 32)
|
|
72
|
+
budget_json = JSON.generate(budget)
|
|
73
|
+
samples['budget_sha256'] = Digest::SHA256.hexdigest(budget_json)
|
|
74
|
+
Dir.mktmpdir('haveapi-live-allocation-') do |directory|
|
|
75
|
+
File.write(File.join(directory, 'samples.json'), JSON.generate(samples))
|
|
76
|
+
File.write(File.join(directory, 'budget.json'), budget_json)
|
|
77
|
+
_out, _err, status = Open3.capture3(RbConfig.ruby, File.expand_path('../../benchmarks/endpoints.rb', __dir__), 'compare',
|
|
78
|
+
'--input', File.join(directory, 'samples.json'), '--budgets', File.join(directory, 'budget.json'),
|
|
79
|
+
'--output', File.join(directory, 'report'))
|
|
80
|
+
expect(status.exitstatus).to eq(1)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
it 'fails the actual worker CLI on an extra live SQLite query and wrong output' do
|
|
85
|
+
%w[query wrong-output].each do |control|
|
|
86
|
+
code, evidence = live_worker(control)
|
|
87
|
+
expect(code).to eq(3)
|
|
88
|
+
expect(evidence).to include(control == 'query' ? 'counter' : 'Unexpected output')
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
it 'sets its policy before loading SQLite and emits clean JSON with absent or development RACK_ENV' do
|
|
93
|
+
[nil, 'development'].each do |rack_env|
|
|
94
|
+
code, result = live_worker('sqlite-clean', rack_env:)
|
|
95
|
+
expect(code).to eq(0), result.inspect
|
|
96
|
+
expect(result['runtime_policy']).to eq(HaveAPI::Performance::RUNTIME_POLICY)
|
|
97
|
+
expect(result['host'].values).to all(be_a(String))
|
|
98
|
+
expect(result['cases'].first['counters']).to eq('operations' => 3, 'record' => { 'queries' => 1, 'authorizations' => 1, 'executions' => 1 })
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
it 'keeps production input validation strict when the root lock is absent but the server lock exists' do
|
|
103
|
+
source = File.expand_path('../../../..', __dir__)
|
|
104
|
+
Dir.mktmpdir('haveapi-production-inputs-') do |harness|
|
|
105
|
+
FileUtils.mkdir_p(File.join(harness, 'servers/ruby'))
|
|
106
|
+
FileUtils.cp(File.join(source, 'servers/ruby/Gemfile.lock'), File.join(harness, 'servers/ruby/Gemfile.lock'))
|
|
107
|
+
stub_const('HaveAPI::Performance::ROOT', harness)
|
|
108
|
+
expect { described_class.inputs(source, 'quick') }.to raise_error(HaveAPI::Performance::ContractError,
|
|
109
|
+
"Missing resolved lock #{harness}/Gemfile.lock")
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
it 'retains the single benchmark bundle and raw unused contributor flakes without library locks' do
|
|
114
|
+
root = File.expand_path('../../../..', __dir__)
|
|
115
|
+
inputs = JSON.parse(JSON.generate(described_class.inputs(root, 'quick', benchmark: true)))
|
|
116
|
+
tool = inputs.fetch('benchmark')
|
|
117
|
+
expect(tool.keys).to match_array(HaveAPI::Performance::BENCHMARK_INPUTS)
|
|
118
|
+
expect(inputs.fetch('locks')).to eq('servers/ruby/benchmarks/Gemfile.lock' => tool.fetch('servers/ruby/benchmarks/Gemfile.lock').fetch('sha256'))
|
|
119
|
+
expect(inputs.fetch('manifests')).to have_key('.ruby-version')
|
|
120
|
+
expect(inputs.fetch('manifests')).not_to have_key('flake.lock')
|
|
121
|
+
expect(inputs.fetch('contributor_flakes').keys).to match_array(%w[flake.nix flake.lock])
|
|
122
|
+
tool.each do |path, input|
|
|
123
|
+
expect(input.fetch('content')).to eq(File.read(File.join(root, path)))
|
|
124
|
+
expect(input.fetch('sha256')).to eq(Digest::SHA256.hexdigest(input.fetch('content')))
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
it 'rejects changed Ruby and unavailable calibrated dependencies before starting workers' do
|
|
129
|
+
samples, json = PerformanceSamples.fixture
|
|
130
|
+
Dir.mktmpdir('haveapi-runtime-guard-') do |directory|
|
|
131
|
+
budget = File.join(directory, 'budgets.json')
|
|
132
|
+
cli = HaveAPI::Performance::CLI.new(['run', '--baseline-root', directory, '--baseline-sha', samples['baseline_sha'],
|
|
133
|
+
'--candidate-root', directory, '--candidate-sha', samples['candidate_sha'],
|
|
134
|
+
'--budgets', budget, '--output', File.join(directory, 'result')])
|
|
135
|
+
cli.send(:parse)
|
|
136
|
+
File.write(budget, json)
|
|
137
|
+
allow(cli).to receive(:child)
|
|
138
|
+
expect { cli.send(:run_samples, 'f' * 64) }.to raise_error(HaveAPI::Performance::ContractError, 'Calibration Ruby mismatch')
|
|
139
|
+
budgets = JSON.parse(json)
|
|
140
|
+
budgets['environment']['weekly']['ruby'] = JSON.parse(JSON.generate(described_class.new({}).environment.fetch(:ruby)))
|
|
141
|
+
budgets['environment']['weekly']['gems'] = { 'haveapi-missing-calibrated-gem' => { 'version' => '99.0', 'platform' => 'ruby' } }
|
|
142
|
+
File.write(budget, JSON.generate(budgets))
|
|
143
|
+
expect { cli.send(:run_samples, 'f' * 64) }.to raise_error(HaveAPI::Performance::ContractError, /Missing calibrated dependency/)
|
|
144
|
+
expect(cli).not_to have_received(:child)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
it 'limits the nonancestor runner exception to an exact quick push event pair' do
|
|
149
|
+
samples, = PerformanceSamples.fixture
|
|
150
|
+
Dir.mktmpdir('haveapi-push-guard-') do |directory|
|
|
151
|
+
event = File.join(directory, 'selection.json')
|
|
152
|
+
selection = samples.slice('baseline_sha', 'candidate_sha').merge('event_name' => 'push', 'profile' => 'quick',
|
|
153
|
+
'push_pair' => { 'event_name' => 'push', 'before' => samples['baseline_sha'], 'after' => samples['candidate_sha'] })
|
|
154
|
+
%w[valid changed missing weekly].each do |control|
|
|
155
|
+
value = Marshal.load(Marshal.dump(selection))
|
|
156
|
+
value['push_pair']['before'] = 'c' * 40 if control == 'changed'
|
|
157
|
+
value.delete('push_pair') if control == 'missing'
|
|
158
|
+
File.write(event, JSON.generate(value))
|
|
159
|
+
cli = HaveAPI::Performance::CLI.new(['run', '--baseline-root', directory, '--baseline-sha', samples['baseline_sha'],
|
|
160
|
+
'--candidate-root', directory, '--candidate-sha', samples['candidate_sha'],
|
|
161
|
+
'--profile', control == 'weekly' ? 'weekly' : 'quick', '--push-event', event,
|
|
162
|
+
'--budgets', '/unused', '--output', '/unused'])
|
|
163
|
+
cli.send(:parse)
|
|
164
|
+
allow(cli).to receive(:verify_runtime)
|
|
165
|
+
allow(HaveAPI::Performance).to receive(:gemspec)
|
|
166
|
+
allow(cli).to receive(:verify_source) { |role| samples['sources'].fetch(role) }
|
|
167
|
+
allow(cli).to receive(:ancestor?).and_return(false)
|
|
168
|
+
allow(HaveAPI::Performance::Release).to receive(:lineage).and_return(nil)
|
|
169
|
+
allow(cli).to receive(:child).and_raise(HaveAPI::Performance::ContractError, 'pilot boundary')
|
|
170
|
+
expect { cli.send(:run_samples, 'f' * 64) }.to raise_error(HaveAPI::Performance::ContractError,
|
|
171
|
+
control == 'valid' ? 'pilot boundary' : /push|Push|Weekly/)
|
|
172
|
+
expect(cli).not_to have_received(:child) unless control == 'valid'
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
it 'validates every response after the timed loop' do
|
|
178
|
+
scenario = Class.new do
|
|
179
|
+
attr_reader :calls, :validations
|
|
180
|
+
|
|
181
|
+
def reset!(iterations:)
|
|
182
|
+
@iterations = iterations
|
|
183
|
+
@calls = 0
|
|
184
|
+
@validations = []
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def call
|
|
188
|
+
@calls += 1
|
|
189
|
+
Rack::MockResponse.new(200, {}, JSON.generate(status: true))
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def validate!(response, index:)
|
|
193
|
+
raise 'Validation ran during request loop' unless @calls == @iterations
|
|
194
|
+
|
|
195
|
+
@validations << index
|
|
196
|
+
HaveAPI::Performance::Identity.json(response)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def counters
|
|
200
|
+
{ operations: Array.new(@iterations) { { queries: 0, authorizations: 0, executions: 0 } } }
|
|
201
|
+
end
|
|
202
|
+
end.new
|
|
203
|
+
result = described_class.new({}).measure(scenario, 4)
|
|
204
|
+
expect(scenario.validations).to eq([0, 1, 2, 3])
|
|
205
|
+
expect(result[:operations]).to eq(4)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
it 'reuses immutable body proof within a sample while checking every operation and resetting between samples' do
|
|
209
|
+
scenario = HaveAPI::PerformanceFixtures::Endpoints.build(id: 'api.list', dimensions: { rows: 32 })
|
|
210
|
+
responses = []
|
|
211
|
+
allow(scenario).to(receive(:call).and_wrap_original { |call| call.call.tap { |response| responses << response } })
|
|
212
|
+
allow(scenario).to receive(:validate!).and_call_original
|
|
213
|
+
allow(scenario).to receive(:validate_body!).and_call_original
|
|
214
|
+
worker = described_class.new({})
|
|
215
|
+
result = worker.measure(scenario, 8)
|
|
216
|
+
expect(scenario).to have_received(:validate!).exactly(8).times
|
|
217
|
+
expect(scenario).to have_received(:validate_body!).once
|
|
218
|
+
expect(result[:bodies].length).to eq(1)
|
|
219
|
+
expect(result[:outputs].length).to eq(1)
|
|
220
|
+
expect(result[:outputs].first['count']).to eq(8)
|
|
221
|
+
expect(result[:counters]).to eq(operations: 8, record: { queries: 0, authorizations: 1, executions: 1 })
|
|
222
|
+
retained = result[:bodies].values.first
|
|
223
|
+
original = retained[:body].dup
|
|
224
|
+
responses.first.body.replace('changed after validation')
|
|
225
|
+
expect(retained[:body]).to eq(original)
|
|
226
|
+
expect { retained[:canonical_body]['response']['probe'].first['count'] = 9 }.to raise_error(FrozenError)
|
|
227
|
+
following = worker.measure(scenario, 3)
|
|
228
|
+
expect(following[:outputs].length).to eq(result[:outputs].length)
|
|
229
|
+
expect(following[:outputs].first['count']).to eq(3)
|
|
230
|
+
expect(scenario).to have_received(:validate_body!).twice
|
|
231
|
+
ensure
|
|
232
|
+
scenario&.close
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
it 'rejects later changed bodies, metadata, collisions and execution records despite an earlier valid body' do
|
|
236
|
+
%w[body status header collision counter].each do |fault|
|
|
237
|
+
scenario = HaveAPI::PerformanceFixtures::Endpoints.build(id: 'api.basic_get', dimensions: {})
|
|
238
|
+
scenario.reset!(iterations: 1)
|
|
239
|
+
valid = scenario.call
|
|
240
|
+
digest = Digest::SHA256.hexdigest(valid.body)
|
|
241
|
+
broken = valid.body.sub('"count":0', '"count":"wrong"')
|
|
242
|
+
calls = 0
|
|
243
|
+
allow(scenario).to receive(:call).and_wrap_original do |call|
|
|
244
|
+
response = call.call
|
|
245
|
+
calls += 1
|
|
246
|
+
next response unless calls == 3
|
|
247
|
+
|
|
248
|
+
case fault
|
|
249
|
+
when 'body', 'collision' then Rack::MockResponse.new(200, response.headers, broken)
|
|
250
|
+
when 'status' then Rack::MockResponse.new(403, response.headers, response.body)
|
|
251
|
+
when 'header' then Rack::MockResponse.new(200, { 'content-type' => 'text/plain' }, response.body)
|
|
252
|
+
else
|
|
253
|
+
scenario.instance_variable_get(:@state).operations[2][:executions] = 0
|
|
254
|
+
response
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
if fault == 'collision'
|
|
258
|
+
allow(Digest::SHA256).to receive(:hexdigest).and_call_original
|
|
259
|
+
allow(Digest::SHA256).to receive(:hexdigest).with(broken).and_return(digest)
|
|
260
|
+
end
|
|
261
|
+
expect { described_class.new({}).measure(scenario, 3) }.to raise_error(StandardError)
|
|
262
|
+
scenario.close
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
it 'does not reuse proof across authorization roles or trust a forged first canonical body' do
|
|
267
|
+
admin = HaveAPI::PerformanceFixtures::Endpoints.build(id: 'api.list', dimensions: { rows: 1, user: 'admin' })
|
|
268
|
+
member = HaveAPI::PerformanceFixtures::Endpoints.build(id: 'api.list', dimensions: { rows: 1, user: 'member' })
|
|
269
|
+
worker = described_class.new({})
|
|
270
|
+
administrative = worker.measure(admin, 2)
|
|
271
|
+
body = administrative[:bodies].values.first.fetch(:body)
|
|
272
|
+
allow(member).to receive(:call).and_wrap_original do |call|
|
|
273
|
+
response = call.call
|
|
274
|
+
Rack::MockResponse.new(200, response.headers, body)
|
|
275
|
+
end
|
|
276
|
+
expect { worker.measure(member, 2) }.to raise_error(/Unexpected output/)
|
|
277
|
+
allow(admin).to receive(:validate_body!).and_wrap_original do |validate, response|
|
|
278
|
+
validate.call(response).tap { |identity| identity[:canonical_body]['response']['probe'].first['count'] = 3 }
|
|
279
|
+
end
|
|
280
|
+
expect { worker.measure(admin, 2) }.to raise_error(HaveAPI::Performance::ContractError, %r{raw/canonical})
|
|
281
|
+
expect { worker.measure(admin, 100_001) }.to raise_error(HaveAPI::Performance::ContractError, /iteration/)
|
|
282
|
+
ensure
|
|
283
|
+
admin&.close
|
|
284
|
+
member&.close
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
it 'keeps documentation body proofs within their user/sample and checks uncalled indexes and auth every time' do
|
|
288
|
+
admin = HaveAPI::Performance::DocumentationScenario.new(id: 'docs.html_resources', dimensions: { size: 1, user: 'admin' })
|
|
289
|
+
member = HaveAPI::Performance::DocumentationScenario.new(id: 'docs.html_resources', dimensions: { size: 1, user: 'member' })
|
|
290
|
+
worker = described_class.new({})
|
|
291
|
+
[admin, member].each do |scenario|
|
|
292
|
+
allow(scenario).to receive(:validate_body!).and_call_original
|
|
293
|
+
worker.measure(scenario, 2)
|
|
294
|
+
expect(scenario).to have_received(:validate_body!).once
|
|
295
|
+
end
|
|
296
|
+
member.reset!(iterations: 2)
|
|
297
|
+
response = member.call
|
|
298
|
+
expect { member.validate!(response, index: 1) { raise 'uncalled body proof' } }.to raise_error(HaveAPI::Performance::ContractError, /uncalled/)
|
|
299
|
+
member.instance_variable_get(:@operations).first[:authorizations] = 0
|
|
300
|
+
expect { member.validate!(response, index: 0) { raise 'unchecked counter' } }.to raise_error(HaveAPI::Performance::ContractError, /counters/)
|
|
301
|
+
administrative = worker.measure(admin, 1)[:bodies].values.first[:body]
|
|
302
|
+
allow(member).to receive(:call).and_wrap_original do |call|
|
|
303
|
+
response = call.call
|
|
304
|
+
Rack::MockResponse.new(200, response.headers, administrative)
|
|
305
|
+
end
|
|
306
|
+
expect { worker.measure(member, 2) }.to raise_error(HaveAPI::Performance::ContractError, /authorization/)
|
|
307
|
+
ensure
|
|
308
|
+
admin&.close
|
|
309
|
+
member&.close
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
it 'selects shared counts above twenty thousand with one adjustment and retains cap inadequacy' do
|
|
313
|
+
[false, true].each do |under_cap|
|
|
314
|
+
pilot_seconds = under_cap ? 0.000001 : 0.000025
|
|
315
|
+
samples, = PerformanceSamples.fixture
|
|
316
|
+
cli = HaveAPI::Performance::CLI.new(['run', '--baseline-root', '/controlled/baseline', '--baseline-sha', samples['baseline_sha'],
|
|
317
|
+
'--candidate-root', '/controlled/candidate', '--candidate-sha', samples['candidate_sha'],
|
|
318
|
+
'--budgets', '/unused', '--output', '/unused'])
|
|
319
|
+
cli.send(:parse)
|
|
320
|
+
allow(cli).to receive(:verify_runtime)
|
|
321
|
+
allow(HaveAPI::Performance).to receive(:gemspec)
|
|
322
|
+
allow(cli).to receive(:verify_source) { |role| samples['sources'].fetch(role) }
|
|
323
|
+
allow(cli).to receive(:ancestor?).and_return(true)
|
|
324
|
+
allow(HaveAPI::Performance::Release).to receive(:lineage).and_return(nil)
|
|
325
|
+
requests = []
|
|
326
|
+
item = samples['inventory'].first
|
|
327
|
+
key = HaveAPI::Performance::Identity.key(item)
|
|
328
|
+
allow(cli).to receive(:child) do |role, counts, label, *_order|
|
|
329
|
+
requests << [role, counts.dup, label]
|
|
330
|
+
cli.instance_variable_set(:@common, samples.slice('environment', 'inputs'))
|
|
331
|
+
duration = if label == 'pilot'
|
|
332
|
+
pilot_seconds
|
|
333
|
+
elsif label == 'calibration-adjusted'
|
|
334
|
+
0.000009
|
|
335
|
+
else
|
|
336
|
+
0.00001
|
|
337
|
+
end
|
|
338
|
+
duration = 0.000001 if under_cap
|
|
339
|
+
duration = 0.9 if role == 'candidate'
|
|
340
|
+
{ 'inventory' => [item], 'cases' => [item.merge('wall_seconds' => duration)] }
|
|
341
|
+
end
|
|
342
|
+
result = cli.send(:run_samples, samples['budget_sha256'])['cases'].first
|
|
343
|
+
expect(result['iterations']).to eq(100_000)
|
|
344
|
+
expect(result['pilot']['capped']).to be(true)
|
|
345
|
+
expect(result['pilot']['adequate_signal']).to eq(!under_cap)
|
|
346
|
+
expect(requests.count { |request| request.last == 'calibration-adjusted' }).to eq(under_cap ? 0 : 1)
|
|
347
|
+
requests.reject { |request| %w[pilot calibration].include?(request.last) }.each do |_role, counts, _label|
|
|
348
|
+
expect(counts.fetch(key)).to eq(100_000)
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
it 'detects a live repeated-operation slowdown well below tenfold with per-request normalization' do
|
|
354
|
+
scenario_class = Class.new do
|
|
355
|
+
def initialize(delay)
|
|
356
|
+
@delay = delay
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def reset!(iterations:)
|
|
360
|
+
@iterations = iterations
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def call
|
|
364
|
+
sleep @delay
|
|
365
|
+
Rack::MockResponse.new(200, {}, JSON.generate(status: true, response: { zero: 0, no: false, empty: '', null: nil }))
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def validate!(response, index:)
|
|
369
|
+
HaveAPI::Performance::Identity.json(response)
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def counters
|
|
373
|
+
{ operations: Array.new(@iterations) { { queries: 0, authorizations: 1, executions: 1 } } }
|
|
374
|
+
end
|
|
375
|
+
end
|
|
376
|
+
measured = [0.003, 0.0045].map do |delay|
|
|
377
|
+
JSON.parse(JSON.generate(described_class.new({}).measure(scenario_class.new(delay), 256)))
|
|
378
|
+
end
|
|
379
|
+
samples, json = PerformanceSamples.fixture
|
|
380
|
+
item = samples['cases'].first
|
|
381
|
+
item['iterations'] = 256
|
|
382
|
+
(item['self_pairs'] + item['pairs']).each_with_index do |pair, index|
|
|
383
|
+
%w[baseline candidate].each do |role|
|
|
384
|
+
observation = pair[role]['observation']
|
|
385
|
+
trace = measured[index < item['self_pairs'].length || role == 'baseline' ? 0 : 1]
|
|
386
|
+
pair[role] = trace.merge('observation' => observation)
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
budget = JSON.parse(json)
|
|
390
|
+
allocations = measured.first.fetch('allocated_objects')
|
|
391
|
+
budget['profiles']['quick'].first.merge!('reference_allocations' => allocations,
|
|
392
|
+
'allocation_ceiling' => (allocations * 1.25).ceil + 32)
|
|
393
|
+
json = JSON.generate(budget)
|
|
394
|
+
samples['budget_sha256'] = Digest::SHA256.hexdigest(json)
|
|
395
|
+
report = HaveAPI::Performance::Comparator.new(samples, budget, budget_sha256: samples['budget_sha256']).compare
|
|
396
|
+
expect(report['status']).to eq('regression')
|
|
397
|
+
expect(report['cases'].first['timing']['wall_seconds']['classification']).to eq('regression')
|
|
398
|
+
expect(measured.first['stages']['request_seconds']).to be >= 0.75
|
|
399
|
+
expect(measured.last['wall_seconds'] / measured.first['wall_seconds']).to be_between(1.20, 3.0)
|
|
400
|
+
warn JSON.generate(control: 'live repeated slowdown', operations: 256,
|
|
401
|
+
baseline_batch_seconds: measured.first['stages']['request_seconds'],
|
|
402
|
+
candidate_batch_seconds: measured.last['stages']['request_seconds'])
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
[false, true].each do |expensive|
|
|
406
|
+
it "preserves source/replay composition and common #{expensive ? 'expensive' : 'cheap'} count/warmup policy" do
|
|
407
|
+
samples, budget_json = PerformanceSamples.fixture
|
|
408
|
+
budgets = JSON.parse(budget_json)
|
|
409
|
+
Dir.mktmpdir('haveapi-run-composition-') do |directory|
|
|
410
|
+
policy = File.join(directory, 'budgets.json')
|
|
411
|
+
output = File.join(directory, 'result')
|
|
412
|
+
File.write(policy, budget_json)
|
|
413
|
+
%w[baseline candidate].each do |role|
|
|
414
|
+
root = File.join(directory, role)
|
|
415
|
+
Dir.mkdir(root)
|
|
416
|
+
samples['sources'][role]['root'] = root
|
|
417
|
+
end
|
|
418
|
+
cli = HaveAPI::Performance::CLI.new(['run', '--baseline-root', samples['sources']['baseline']['root'],
|
|
419
|
+
'--baseline-sha', samples['baseline_sha'],
|
|
420
|
+
'--candidate-root', samples['sources']['candidate']['root'],
|
|
421
|
+
'--candidate-sha', samples['candidate_sha'], '--budgets', policy, '--output', output])
|
|
422
|
+
# Control source verification and worker replies, retaining the real run
|
|
423
|
+
# assembly, child protocol validation, direct comparator and report path.
|
|
424
|
+
allow(cli).to receive(:verify_runtime)
|
|
425
|
+
allow(HaveAPI::Performance).to receive(:gemspec)
|
|
426
|
+
allow(cli).to receive_messages(git: '', ancestor?: true)
|
|
427
|
+
allow(HaveAPI::Performance::Release).to receive(:lineage).and_return(nil)
|
|
428
|
+
allow(cli).to receive(:verify_source) { |role| samples['sources'].fetch(role).transform_keys(&:to_sym) }
|
|
429
|
+
requests = []
|
|
430
|
+
allow(cli).to receive(:capture_worker) do |_environment, worker_request, label|
|
|
431
|
+
request = JSON.parse(JSON.generate(worker_request))
|
|
432
|
+
requests << [label, request]
|
|
433
|
+
role = samples['sources'].find { |_name, source| source['sha'] == request['source_sha'] }.first
|
|
434
|
+
source = samples['sources'].fetch(role).merge('loaded_sources' => { 'server.rb' => 'd' * 64 })
|
|
435
|
+
observation = samples['observations'].fetch(role).merge('source' => source, 'fixture_sha256' => described_class.fixture_hashes,
|
|
436
|
+
'workload_order' => request['workload_order'])
|
|
437
|
+
items = samples['cases'].map do |entry|
|
|
438
|
+
count = request['counts'].fetch(HaveAPI::Performance::Identity.key(entry), 1)
|
|
439
|
+
sample = entry['pairs'].first.fetch(role)
|
|
440
|
+
sample.merge(entry.slice('id', 'dimensions')).merge('operations' => count,
|
|
441
|
+
'outputs' => sample['outputs'].map { |example| example.merge('count' => count) },
|
|
442
|
+
'counters' => { 'operations' => count, 'record' => sample['counters']['record'] },
|
|
443
|
+
'wall_seconds' => if expensive
|
|
444
|
+
2.0
|
|
445
|
+
else
|
|
446
|
+
(label == 'pilot' ? 0.9 : 0.25)
|
|
447
|
+
end, 'cpu_seconds' => if expensive
|
|
448
|
+
2.0
|
|
449
|
+
else
|
|
450
|
+
(label == 'pilot' ? 0.9 : 0.25)
|
|
451
|
+
end)
|
|
452
|
+
end
|
|
453
|
+
result = observation.merge('schema_version' => HaveAPI::Performance::SAMPLE_SCHEMA, 'inventory' => samples['inventory'], 'cases' => items)
|
|
454
|
+
[JSON.generate(result), '', instance_double(Process::Status, success?: true)]
|
|
455
|
+
end
|
|
456
|
+
expect(cli.execute).to eq(0)
|
|
457
|
+
retained = JSON.parse(File.read(File.join(output, 'samples.json')))
|
|
458
|
+
expect(retained['sources']).to eq(samples['sources'])
|
|
459
|
+
saved_report = JSON.parse(File.read(File.join(output, 'report.json')))
|
|
460
|
+
expect(saved_report.dig('comparison', 'samples_sha256')).to eq(Digest::SHA256.file(File.join(output, 'samples.json')).hexdigest)
|
|
461
|
+
expect(saved_report['harness_sha256']).to eq(retained['harness_sha256'])
|
|
462
|
+
report = HaveAPI::Performance::Comparator.new(retained, budgets, budget_sha256: Digest::SHA256.hexdigest(budget_json)).compare
|
|
463
|
+
expect(report['status']).to eq('pass')
|
|
464
|
+
expect(requests.map(&:first).count('calibration-adjusted')).to eq(expensive ? 0 : 1)
|
|
465
|
+
expect(retained['cases'].map { |item| item['iterations'] }).to all(eq(expensive ? 1 : 4))
|
|
466
|
+
expect(retained['cases'].map { |item| item['pilot']['baseline_batch_seconds'] }).to all(eq(expensive ? 2.0 : 1.0))
|
|
467
|
+
requests.each do |label, request|
|
|
468
|
+
next if %w[pilot calibration].include?(label)
|
|
469
|
+
|
|
470
|
+
expect(request['counts'].values).to all(eq(expensive ? 1 : 4))
|
|
471
|
+
expect(request['warmup_counts'].values).to all(eq(expensive ? 0 : 1))
|
|
472
|
+
end
|
|
473
|
+
end
|
|
474
|
+
end
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
it 'terminates and reaps stalled API, SQLite and documentation batches with source and phase diagnostics' do
|
|
478
|
+
stub_const('HaveAPI::Performance::REQUEST_LIMIT_SECONDS', 0.05)
|
|
479
|
+
%w[api.public_get sqlite.page docs.html_rust].zip(%w[pilot warmup-0 measured]).each do |workload, phase|
|
|
480
|
+
Dir.mktmpdir('haveapi-watchdog-') do |directory|
|
|
481
|
+
cli = HaveAPI::Performance::CLI.new([])
|
|
482
|
+
cli.instance_variable_set(:@output, directory)
|
|
483
|
+
program = <<~RUBY
|
|
484
|
+
require 'json'
|
|
485
|
+
request = JSON.parse(STDIN.read)
|
|
486
|
+
progress = IO.for_fd(request.fetch('progress_fd'))
|
|
487
|
+
STDOUT.sync = STDERR.sync = true
|
|
488
|
+
puts Process.pid
|
|
489
|
+
warn 'before stalled request'
|
|
490
|
+
progress.puts(JSON.generate(state: 'request', workload: #{workload.inspect}, phase: #{phase.inspect}))
|
|
491
|
+
progress.flush
|
|
492
|
+
sleep 10
|
|
493
|
+
RUBY
|
|
494
|
+
allow(cli).to receive(:worker_command).and_return([RbConfig.ruby, '-e', program])
|
|
495
|
+
expect do
|
|
496
|
+
cli.send(:capture_worker, {}, { source_sha: 'a' * 40 }, 'controlled')
|
|
497
|
+
end.to raise_error(HaveAPI::Performance::WorkerTimeout) { |error|
|
|
498
|
+
expect(error.details).to include(kind: 'request_batch_timeout', workload:, source_sha: 'a' * 40, limit_seconds: 0.05)
|
|
499
|
+
expect(error.details[:phase]).to include(phase)
|
|
500
|
+
}
|
|
501
|
+
pid = File.read(File.join(directory, 'controlled.json')).to_i
|
|
502
|
+
expect(pid).to be > 0
|
|
503
|
+
expect { Process.kill(0, pid) }.to raise_error(Errno::ESRCH)
|
|
504
|
+
expect(File.read(File.join(directory, 'controlled.stderr.log'))).to include('before stalled request')
|
|
505
|
+
end
|
|
506
|
+
end
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
it 'distinguishes a worker setup timeout from request work' do
|
|
510
|
+
stub_const('HaveAPI::Performance::SETUP_LIMIT_SECONDS', 0.05)
|
|
511
|
+
Dir.mktmpdir('haveapi-setup-watchdog-') do |directory|
|
|
512
|
+
cli = HaveAPI::Performance::CLI.new([])
|
|
513
|
+
cli.instance_variable_set(:@output, directory)
|
|
514
|
+
allow(cli).to receive(:worker_command).and_return([RbConfig.ruby, '-e', 'sleep 10'])
|
|
515
|
+
expect do
|
|
516
|
+
cli.send(:capture_worker, {}, { source_sha: 'a' * 40 }, 'setup-control')
|
|
517
|
+
end.to raise_error(HaveAPI::Performance::WorkerTimeout) { |error|
|
|
518
|
+
expect(error.details[:kind]).to eq('worker_setup_timeout')
|
|
519
|
+
}
|
|
520
|
+
end
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
it 'fails closed on unavailable immutable source and preserves existing output evidence' do
|
|
524
|
+
Dir.mktmpdir('haveapi-runner-') do |directory|
|
|
525
|
+
output = File.join(directory, 'existing')
|
|
526
|
+
Dir.mkdir(output)
|
|
527
|
+
sentinel = File.join(output, 'report.json')
|
|
528
|
+
File.write(sentinel, 'existing evidence')
|
|
529
|
+
cli = HaveAPI::Performance::CLI.new(['compare', '--input', 'absent', '--budgets', 'absent', '--output', output])
|
|
530
|
+
expect(cli.execute).to eq(3)
|
|
531
|
+
expect(File.read(sentinel)).to eq('existing evidence')
|
|
532
|
+
missing = File.join(directory, 'missing')
|
|
533
|
+
budget = File.join(directory, 'budgets.json')
|
|
534
|
+
File.write(budget, PerformanceSamples.fixture.last)
|
|
535
|
+
cli = HaveAPI::Performance::CLI.new(['run', '--baseline-root', directory, '--baseline-sha', '0' * 40,
|
|
536
|
+
'--candidate-root', directory, '--candidate-sha', '1' * 40,
|
|
537
|
+
'--budgets', budget, '--output', missing])
|
|
538
|
+
allow(cli).to receive(:verify_runtime)
|
|
539
|
+
expect(cli.execute).to eq(3)
|
|
540
|
+
report = JSON.parse(File.read(File.join(missing, 'report.json')))
|
|
541
|
+
expect(report['error']['message']).to include('Source SHA must be full immutable SHA')
|
|
542
|
+
expect(report.values_at('baseline_sha', 'candidate_sha')).to eq(['0' * 40, '1' * 40])
|
|
543
|
+
expect(report).not_to have_key('harness_sha256')
|
|
544
|
+
expect(report.fetch('comparison')).not_to have_key('samples_sha256')
|
|
545
|
+
equal = HaveAPI::Performance::CLI.new(['run', '--baseline-root', directory, '--baseline-sha', 'a' * 40,
|
|
546
|
+
'--candidate-root', directory, '--candidate-sha', 'a' * 40,
|
|
547
|
+
'--budgets', budget, '--output', File.join(directory, 'equal')])
|
|
548
|
+
expect(equal.execute).to eq(3)
|
|
549
|
+
end
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
it 'checks declared Ruby and runtime requirements against installed tools including unloaded rake' do
|
|
553
|
+
campaign = HaveAPI::PerformanceCI::Campaign.new(repository: instance_double(HaveAPI::PerformanceCI::Repository),
|
|
554
|
+
selection: {}, evidence: '/unused', sources: '/unused')
|
|
555
|
+
spec = { 'ruby' => '>= 3.2', 'dependencies' => [['rake', 'runtime', '>= 13.0'], ['haveapi-client', 'runtime', '~> 0.30.0']] }
|
|
556
|
+
status = instance_double(Process::Status, success?: true)
|
|
557
|
+
allow(Open3).to receive(:capture3).and_return([JSON.generate(spec), '', status])
|
|
558
|
+
expect(campaign.send(:gemspec, '/source', 'servers/ruby/haveapi.gemspec')).to eq(spec)
|
|
559
|
+
changed = spec.merge('ruby' => '>= 99')
|
|
560
|
+
allow(Open3).to receive(:capture3).and_return([JSON.generate(changed), '', status])
|
|
561
|
+
expect { campaign.send(:gemspec, '/source', 'servers/ruby/haveapi.gemspec') }.to raise_error(HaveAPI::PerformanceCI::Error, /Benchmark Ruby/)
|
|
562
|
+
changed = spec.merge('dependencies' => [['rake', 'runtime', '>= 99']])
|
|
563
|
+
allow(Open3).to receive(:capture3).and_return([JSON.generate(changed), '', status])
|
|
564
|
+
expect { campaign.send(:gemspec, '/source', 'servers/ruby/haveapi.gemspec') }.to raise_error(HaveAPI::PerformanceCI::Error, /Benchmark dependencies/)
|
|
565
|
+
end
|
|
566
|
+
|
|
567
|
+
it 'rejects unsupported Ruby or gem requirements in either direct source before the first child' do
|
|
568
|
+
samples, = PerformanceSamples.fixture
|
|
569
|
+
Dir.mktmpdir('haveapi-source-requirements-') do |directory|
|
|
570
|
+
roots = %w[baseline candidate].to_h { |role| [role, File.join(directory, role)] }
|
|
571
|
+
paths = HaveAPI::Performance::Release::VERSIONS.map { |path, name| "#{path.split('/lib/').first}/#{name}.gemspec" }
|
|
572
|
+
valid = <<~RUBY
|
|
573
|
+
Gem::Specification.new do |spec|
|
|
574
|
+
spec.name = 'haveapi-fixture'
|
|
575
|
+
spec.version = '1.0.0'
|
|
576
|
+
spec.required_ruby_version = '>= 3.2'
|
|
577
|
+
spec.add_dependency 'rake', '>= 13.0'
|
|
578
|
+
end
|
|
579
|
+
RUBY
|
|
580
|
+
roots.each_value do |root|
|
|
581
|
+
paths.each do |path|
|
|
582
|
+
FileUtils.mkdir_p(File.dirname(File.join(root, path)))
|
|
583
|
+
File.write(File.join(root, path), valid)
|
|
584
|
+
end
|
|
585
|
+
end
|
|
586
|
+
%w[baseline candidate].product(%w[valid ruby dependency]).each do |role, fault|
|
|
587
|
+
path = File.join(roots.fetch(role), paths.last)
|
|
588
|
+
text = case fault
|
|
589
|
+
when 'ruby' then valid.sub('>= 3.2', '>= 99')
|
|
590
|
+
when 'dependency' then valid.sub('>= 13.0', '>= 99')
|
|
591
|
+
else valid
|
|
592
|
+
end
|
|
593
|
+
File.write(path, text)
|
|
594
|
+
cli = HaveAPI::Performance::CLI.new(['run', '--baseline-root', roots['baseline'], '--baseline-sha', samples['baseline_sha'],
|
|
595
|
+
'--candidate-root', roots['candidate'], '--candidate-sha', samples['candidate_sha'],
|
|
596
|
+
'--budgets', '/unused', '--output', '/unused'])
|
|
597
|
+
cli.send(:parse)
|
|
598
|
+
allow(cli).to receive(:verify_runtime)
|
|
599
|
+
allow(cli).to receive(:verify_source) { |name| { root: roots.fetch(name), sha: samples.fetch("#{name}_sha") } }
|
|
600
|
+
allow(cli).to receive(:ancestor?).and_return(true)
|
|
601
|
+
allow(HaveAPI::Performance::Release).to receive(:lineage).and_return(nil)
|
|
602
|
+
allow(cli).to receive(:child).and_raise(HaveAPI::Performance::ContractError, 'pilot boundary')
|
|
603
|
+
expect { cli.send(:run_samples, 'f' * 64) }.to raise_error(HaveAPI::Performance::ContractError,
|
|
604
|
+
fault == 'valid' ? 'pilot boundary' : /Benchmark Ruby|Benchmark dependencies/)
|
|
605
|
+
expect(cli).not_to have_received(:child) unless fault == 'valid'
|
|
606
|
+
File.write(path, valid)
|
|
607
|
+
end
|
|
608
|
+
end
|
|
609
|
+
end
|
|
610
|
+
end
|