haveapi 0.30.1 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +1 -0
  3. data/Gemfile +1 -0
  4. data/benchmarks/Gemfile +30 -0
  5. data/benchmarks/Gemfile.lock +296 -0
  6. data/benchmarks/documentation.rb +277 -0
  7. data/benchmarks/endpoints.rb +786 -0
  8. data/benchmarks/performance_budgets.json +1354 -0
  9. data/benchmarks/performance_compare.rb +678 -0
  10. data/benchmarks/shell.nix +21 -0
  11. data/doc/client-examples.md +179 -26
  12. data/doc/performance.md +310 -0
  13. data/haveapi.gemspec +1 -1
  14. data/lib/haveapi/client_examples/ts_generated_client.rb +36 -0
  15. data/lib/haveapi/documentation_clients.rb +49 -6
  16. data/lib/haveapi/documentation_context.rb +42 -12
  17. data/lib/haveapi/version.rb +1 -1
  18. data/lib/haveapi/views/version_page.erb +2 -2
  19. data/spec/client_examples/context_performance_spec.rb +72 -0
  20. data/spec/client_examples/context_spec.rb +206 -0
  21. data/spec/client_examples/profile_mount_spec.rb +45 -0
  22. data/spec/client_examples/ts_generated_client_spec.rb +53 -0
  23. data/spec/client_examples/typescript_profiles_spec.rb +113 -0
  24. data/spec/documentation/client_examples_spec.rb +2 -2
  25. data/spec/documentation/performance_spec.rb +79 -0
  26. data/spec/performance/comparator_spec.rb +472 -0
  27. data/spec/performance/endpoint_contract_spec.rb +226 -0
  28. data/spec/performance/endpoint_scaling_spec.rb +82 -0
  29. data/spec/performance/event_baseline_spec.rb +407 -0
  30. data/spec/performance/json_activation_spec.rb +151 -0
  31. data/spec/performance/release_lineage_spec.rb +470 -0
  32. data/spec/performance/runner_spec.rb +610 -0
  33. data/spec/performance/sqlite_spec.rb +194 -0
  34. data/spec/performance/support/live_worker.rb +44 -0
  35. data/spec/performance/support/samples.rb +72 -0
  36. data/test_support/documentation_performance_fixture.rb +106 -0
  37. data/test_support/performance_endpoint_fixture.rb +350 -0
  38. data/test_support/performance_sqlite_fixture.rb +384 -0
  39. metadata +29 -3
@@ -0,0 +1,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