haveapi 0.30.0 → 0.30.2

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