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,786 @@
|
|
|
1
|
+
require_relative 'performance_compare'
|
|
2
|
+
require 'base64'
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'etc'
|
|
5
|
+
require 'open3'
|
|
6
|
+
require 'optparse'
|
|
7
|
+
require 'rbconfig'
|
|
8
|
+
require 'socket'
|
|
9
|
+
require 'io/wait'
|
|
10
|
+
|
|
11
|
+
module HaveAPI
|
|
12
|
+
module Performance
|
|
13
|
+
ROOT = File.expand_path('../../..', __dir__)
|
|
14
|
+
FIXTURE_PATHS = %w[performance_endpoint_fixture.rb performance_sqlite_fixture.rb documentation_performance_fixture.rb].freeze
|
|
15
|
+
|
|
16
|
+
class WorkerTimeout < ContractError
|
|
17
|
+
attr_reader :details
|
|
18
|
+
|
|
19
|
+
def initialize(details)
|
|
20
|
+
@details = details
|
|
21
|
+
super("#{details[:kind]}: #{details[:source_sha]} #{details[:workload]} #{details[:phase]} exceeded #{details[:limit_seconds]}s")
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
class DocumentationScenario
|
|
26
|
+
# Only benchmark processes install this observer. It does not change the
|
|
27
|
+
# authorization result or retain a request beyond its timed call.
|
|
28
|
+
module AuthorizationObserver
|
|
29
|
+
def authorized?(...)
|
|
30
|
+
record = Thread.current[:haveapi_performance_documentation]
|
|
31
|
+
record[:authorizations] += 1 if record
|
|
32
|
+
super
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.cases(profile:)
|
|
37
|
+
quick = %w[docs.options docs.html_resources docs.html_actions].map { |id| { id:, dimensions: { size: 8 } } }
|
|
38
|
+
complex = Performance.complex_cases(profile).map { |spec| JSON.parse(JSON.generate(spec), symbolize_names: true) }
|
|
39
|
+
return quick + complex if profile == 'quick'
|
|
40
|
+
|
|
41
|
+
quick + complex + %w[docs.options docs.html_resources docs.html_actions].product(%w[admin member]).map do |id, user|
|
|
42
|
+
{ id:, dimensions: { size: 16, user: } }
|
|
43
|
+
end + %w[rust.async_example rust.async_auth rust.blocking_example rust.blocking_auth].map do |id|
|
|
44
|
+
{ id:, dimensions: { size: 16, user: 'admin' } }
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def initialize(spec)
|
|
49
|
+
@id = spec.fetch(:id)
|
|
50
|
+
dimensions = spec.fetch(:dimensions)
|
|
51
|
+
size = dimensions.fetch(:size, 1)
|
|
52
|
+
@user = dimensions.fetch(:user, 'admin')
|
|
53
|
+
resources, actions = @id == 'docs.html_actions' ? [1, size] : [size, 1]
|
|
54
|
+
@server = DocumentationPerformanceFixture.build(resource_count: dimensions.fetch(:resources, resources),
|
|
55
|
+
actions_per_resource: dimensions.fetch(:actions, actions),
|
|
56
|
+
samples_per_action: dimensions.fetch(:samples, 1),
|
|
57
|
+
parameters_per_action: dimensions.fetch(:parameters, 2),
|
|
58
|
+
rust: @id.start_with?('rust.') || @id == 'docs.html_rust')
|
|
59
|
+
@dimensions = dimensions
|
|
60
|
+
Authorization.prepend(AuthorizationObserver) unless Authorization.ancestors.include?(AuthorizationObserver)
|
|
61
|
+
@request = Rack::MockRequest.new(@server.app)
|
|
62
|
+
@headers = { 'HTTP_HOST' => 'example.test', 'HTTP_ACCEPT_LANGUAGE' => 'en',
|
|
63
|
+
'HTTP_AUTHORIZATION' => "Basic #{Base64.strict_encode64("#{@user}:fixture-only")}" }
|
|
64
|
+
@url = 'http://example.test/benchmark/api/v1/'
|
|
65
|
+
prepare_rust if @id.start_with?('rust.')
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def reset!(iterations:)
|
|
69
|
+
@operations = Array.new(iterations) { { queries: 0, authorizations: 0, executions: 0 } }
|
|
70
|
+
@index = 0
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def call
|
|
74
|
+
Thread.current[:haveapi_performance_documentation] = @operations.fetch(@index)
|
|
75
|
+
@index += 1
|
|
76
|
+
if @id.start_with?('rust.')
|
|
77
|
+
kind = @id.end_with?('_auth') ? :auth : :example
|
|
78
|
+
result = @profile.adapter.render(kind:, context: @context, mode: @id.include?('blocking') ? :blocking : :async,
|
|
79
|
+
auth_method: :basic, auth_description: @auth)
|
|
80
|
+
Rack::MockResponse.new(200, { 'content-type' => 'application/json' }, JSON.generate(result))
|
|
81
|
+
elsif @id == 'docs.options'
|
|
82
|
+
@request.options(@url, **@headers, 'HTTP_ACCEPT' => 'application/json')
|
|
83
|
+
else
|
|
84
|
+
@request.get(@url, **@headers)
|
|
85
|
+
end
|
|
86
|
+
ensure
|
|
87
|
+
Thread.current[:haveapi_performance_documentation] = nil
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def validate!(response, index:)
|
|
91
|
+
unless index.is_a?(Integer) && index >= 0 && index < @index
|
|
92
|
+
raise ContractError, 'Invalid or uncalled documentation operation index'
|
|
93
|
+
end
|
|
94
|
+
raise ContractError, 'Documentation response failed' unless response.status == 200
|
|
95
|
+
|
|
96
|
+
content_type = @id.start_with?('rust.') || @id == 'docs.options' ? 'application/json' : 'text/html'
|
|
97
|
+
raise ContractError, 'Documentation content type differs' unless response['content-type']&.start_with?(content_type)
|
|
98
|
+
|
|
99
|
+
authorizations = if @id.start_with?('rust.')
|
|
100
|
+
0
|
|
101
|
+
elsif @id == 'docs.html_rust'
|
|
102
|
+
(@dimensions.fetch(:resources) * @dimensions.fetch(:actions)) + 1
|
|
103
|
+
else
|
|
104
|
+
@dimensions.fetch(:size) + 1
|
|
105
|
+
end
|
|
106
|
+
unless @operations.fetch(index) == { queries: 0, authorizations:, executions: 0 }
|
|
107
|
+
raise ContractError, 'Documentation operation counters differ'
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
return yield(-> { validate_body!(response) }) if block_given?
|
|
111
|
+
|
|
112
|
+
validate_body!(response)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def validate_body!(response)
|
|
116
|
+
if @id.start_with?('rust.')
|
|
117
|
+
value = JSON.parse(response.body)
|
|
118
|
+
raise ContractError, 'Rust code missing' unless value['status'] == 'code' && value['code'].is_a?(String) && !value['code'].empty?
|
|
119
|
+
|
|
120
|
+
return { http_status: 200, envelope_status: nil, body_bytes: response.body.bytesize,
|
|
121
|
+
body_sha256: Digest::SHA256.hexdigest(response.body), canonical_body: value }
|
|
122
|
+
end
|
|
123
|
+
if @id == 'docs.options'
|
|
124
|
+
identity = Identity.json(response)
|
|
125
|
+
raise ContractError, 'Documentation resources missing' unless identity[:canonical_body].fetch('response').fetch('resources').any?
|
|
126
|
+
|
|
127
|
+
return identity
|
|
128
|
+
end
|
|
129
|
+
unless response.body.include?('PUBLIC_SAMPLE_0') && response.body.include?('init-python-sync') && response.body.include?('init-examples')
|
|
130
|
+
raise ContractError, 'Documentation panels missing'
|
|
131
|
+
end
|
|
132
|
+
if response.body.include?('ADMIN_ONLY_REQUEST') != (@user == 'admin')
|
|
133
|
+
raise ContractError, 'Documentation authorization mismatch'
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
if @id == 'docs.html_rust'
|
|
137
|
+
actions = @dimensions.fetch(:resources) * @dimensions.fetch(:actions)
|
|
138
|
+
unless response.body.include?('init-rust-async') && response.body.include?('init-rust-blocking') &&
|
|
139
|
+
response.body.include?("example-resource#{@dimensions.fetch(:resources) - 1}-action3-1")
|
|
140
|
+
raise ContractError, 'Full Rust documentation page missing panels/actions/examples'
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
markers = %w[ADMIN_ONLY_ACTION ADMIN_ONLY_DEFAULT ADMIN_ONLY_REQUEST ADMIN_ONLY_RESPONSE ADMIN_ONLY_ACTION_SAMPLE admin_field admin_only]
|
|
144
|
+
unless markers.all? { |marker| response.body.include?(marker) == (@user == 'admin') }
|
|
145
|
+
raise ContractError, 'Full Rust documentation authorization mismatch'
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
examples = response.body.scan(/id="example-resource\d+-(?:action\d+|admin_only)-\d+-examples"/).uniq.length
|
|
149
|
+
expected_examples = (actions * (@dimensions.fetch(:samples) + (@user == 'admin' ? 1 : 0))) + (@user == 'admin' ? 1 : 0)
|
|
150
|
+
unless examples == expected_examples && response.body.include?('documentation_performance_client')
|
|
151
|
+
raise ContractError, 'Full Rust documentation example inventory mismatch'
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
@page = { actual_examples: examples, resources: @dimensions.fetch(:resources), actions: actions + (@user == 'admin' ? 1 : 0),
|
|
155
|
+
public_examples: actions * @dimensions.fetch(:samples), user: @user }
|
|
156
|
+
end
|
|
157
|
+
{ http_status: 200, envelope_status: nil, body_bytes: response.body.bytesize,
|
|
158
|
+
body_sha256: Digest::SHA256.hexdigest(response.body), canonical_sha256: Digest::SHA256.hexdigest(response.body) }.tap do |identity|
|
|
159
|
+
identity[:page] = @page if @page
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def counters
|
|
164
|
+
raise ContractError, 'Documentation operations missing' unless @index == @operations.length
|
|
165
|
+
|
|
166
|
+
{ operations: @operations }
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def close; end
|
|
170
|
+
|
|
171
|
+
private
|
|
172
|
+
|
|
173
|
+
def prepare_rust
|
|
174
|
+
response = @request.options(@url, **@headers, 'HTTP_ACCEPT' => 'application/json')
|
|
175
|
+
description = JSON.parse(response.body, symbolize_names: true).fetch(:response)
|
|
176
|
+
@profile = @server.documentation_client_profiles(1).fetch(:rust)
|
|
177
|
+
resource = description.fetch(:resources).fetch(:resource0)
|
|
178
|
+
action = resource.fetch(:actions).fetch(:action0)
|
|
179
|
+
@context = ClientExample::Context.new(version: 1, host: 'example.test', base_url: 'http://example.test/benchmark/api/',
|
|
180
|
+
version_url: @url, description:, profile: @profile, resource_path: ['resource0'],
|
|
181
|
+
resource:, action_name: 'action0', action:, sample: action.fetch(:examples).first)
|
|
182
|
+
@auth = description.fetch(:authentication).fetch(:basic)
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
class Worker
|
|
187
|
+
def initialize(request)
|
|
188
|
+
@request = request
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def run
|
|
192
|
+
started = monotonic
|
|
193
|
+
ENV['RACK_ENV'] = 'test'
|
|
194
|
+
ENV['LC_ALL'] = 'C.UTF-8'
|
|
195
|
+
ENV['LANG'] = 'C.UTF-8'
|
|
196
|
+
ENV['TZ'] = 'UTC'
|
|
197
|
+
ENV['RUBY_YJIT_ENABLE'] = '0'
|
|
198
|
+
policy = RUNTIME_POLICY.merge('yjit' => (defined?(RubyVM::YJIT) && RubyVM::YJIT.enabled?) || false,
|
|
199
|
+
'rjit' => (defined?(RubyVM::RJIT) && RubyVM::RJIT.enabled?) || false)
|
|
200
|
+
raise ContractError, 'JIT must be disabled before worker startup' unless policy == RUNTIME_POLICY
|
|
201
|
+
|
|
202
|
+
source = File.realpath(@request.fetch('source_root'))
|
|
203
|
+
%w[servers/ruby clients/ruby clients/rust].reverse_each { |component| $:.unshift(File.join(source, component, 'lib')) }
|
|
204
|
+
require 'active_record'
|
|
205
|
+
require 'sqlite3'
|
|
206
|
+
require 'haveapi'
|
|
207
|
+
require 'haveapi/client'
|
|
208
|
+
require 'rack/mock'
|
|
209
|
+
FIXTURE_PATHS.each { |name| require File.join(ROOT, 'servers/ruby/test_support', name) }
|
|
210
|
+
inventory = self.class.inventory(@request.fetch('profile'))
|
|
211
|
+
require 'haveapi/rust_client/documentation' if inventory.any? { |spec| spec[:id].start_with?('rust.') || spec[:id] == 'docs.html_rust' }
|
|
212
|
+
load_seconds = monotonic - started
|
|
213
|
+
order = @request.fetch('workload_order', 'forward')
|
|
214
|
+
raise ContractError, 'Unknown workload order' unless %w[forward reverse].include?(order)
|
|
215
|
+
|
|
216
|
+
selected = order == 'reverse' ? inventory.reverse : inventory
|
|
217
|
+
results = selected.map do |spec|
|
|
218
|
+
@current_workload = Identity.key(JSON.parse(JSON.generate(spec)))
|
|
219
|
+
@phase = 'build'
|
|
220
|
+
progress('setup')
|
|
221
|
+
count = @request.fetch('counts').fetch(Identity.key(JSON.parse(JSON.generate(spec))), 1)
|
|
222
|
+
build_started = monotonic
|
|
223
|
+
scenario = self.class.build(spec)
|
|
224
|
+
stages = { build_seconds: monotonic - build_started }
|
|
225
|
+
begin
|
|
226
|
+
warmup_count = @request.fetch('warmup_counts', {}).fetch(@current_workload, 0)
|
|
227
|
+
stages[:warmups] = Array.new(warmup_count == 0 ? 0 : 2) do |index|
|
|
228
|
+
@phase = "warmup-#{index}"
|
|
229
|
+
measure(scenario, warmup_count).fetch(:stages).merge(operations: warmup_count)
|
|
230
|
+
end
|
|
231
|
+
@phase = 'measured'
|
|
232
|
+
result = measure(scenario, count)
|
|
233
|
+
stages[:measured] = result.delete(:stages)
|
|
234
|
+
ensure
|
|
235
|
+
close_started = monotonic
|
|
236
|
+
scenario.close
|
|
237
|
+
stages[:close_seconds] = monotonic - close_started
|
|
238
|
+
end
|
|
239
|
+
result.merge(spec).merge(stages:)
|
|
240
|
+
end
|
|
241
|
+
inputs = if @request['benchmark'] == true
|
|
242
|
+
self.class.inputs(source, @request.fetch('profile'), release: @request['release_versions'] == true, benchmark: true)
|
|
243
|
+
elsif @request['release_versions'] == true
|
|
244
|
+
self.class.inputs(source, @request.fetch('profile'), release: true)
|
|
245
|
+
else
|
|
246
|
+
self.class.inputs(source, @request.fetch('profile'))
|
|
247
|
+
end
|
|
248
|
+
{ schema_version: SAMPLE_SCHEMA, inventory:, cases: results, source: source_identity(source), environment: environment,
|
|
249
|
+
inputs:,
|
|
250
|
+
fixture_sha256: self.class.fixture_hashes,
|
|
251
|
+
runtime_policy: policy, workload_order: order, host: host_identity,
|
|
252
|
+
stages: { require_load_seconds: load_seconds, worker_seconds: monotonic - started } }
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def monotonic
|
|
256
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def host_identity
|
|
260
|
+
cpu = File.file?('/proc/cpuinfo') ? File.read('/proc/cpuinfo')[/^model name\s*:\s*(.+)$/, 1] : RbConfig::CONFIG['host_cpu']
|
|
261
|
+
{ hostname: Socket.gethostname, os: "#{RbConfig::CONFIG['host_os']} #{Etc.uname[:release]}", cpu: cpu.to_s }
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def self.inventory(profile)
|
|
265
|
+
PROFILES.fetch(profile)
|
|
266
|
+
PerformanceFixtures::Endpoints.cases(profile:) + PerformanceFixtures::SQLite.cases(profile:) + DocumentationScenario.cases(profile:)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def self.build(spec)
|
|
270
|
+
case spec.fetch(:id)
|
|
271
|
+
when /^api\./ then PerformanceFixtures::Endpoints.build(spec)
|
|
272
|
+
when /^sqlite\./ then PerformanceFixtures::SQLite.build(spec)
|
|
273
|
+
else DocumentationScenario.new(spec)
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def measure(scenario, iterations)
|
|
278
|
+
raise ContractError, 'Invalid worker iteration count' unless iterations.is_a?(Integer) && iterations.between?(1, MAX_ITERATIONS)
|
|
279
|
+
|
|
280
|
+
reset_started = monotonic
|
|
281
|
+
responses = Array.new(iterations)
|
|
282
|
+
scenario.reset!(iterations:)
|
|
283
|
+
GC.start
|
|
284
|
+
reset_gc_seconds = monotonic - reset_started
|
|
285
|
+
progress('request', iterations:)
|
|
286
|
+
allocated = GC.stat(:total_allocated_objects)
|
|
287
|
+
cpu = Process.clock_gettime(Process::CLOCK_PROCESS_CPUTIME_ID)
|
|
288
|
+
wall = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
289
|
+
iterations.times { |index| responses[index] = scenario.call }
|
|
290
|
+
wall = Process.clock_gettime(Process::CLOCK_MONOTONIC) - wall
|
|
291
|
+
cpu = Process.clock_gettime(Process::CLOCK_PROCESS_CPUTIME_ID) - cpu
|
|
292
|
+
allocations = GC.stat(:total_allocated_objects) - allocated
|
|
293
|
+
progress('setup')
|
|
294
|
+
validation_started = monotonic
|
|
295
|
+
bodies = {}
|
|
296
|
+
proofs = {}
|
|
297
|
+
outputs = {}
|
|
298
|
+
responses.each_with_index do |response, index|
|
|
299
|
+
raw_digest = Digest::SHA256.hexdigest(response.body)
|
|
300
|
+
proof = proofs[raw_digest]
|
|
301
|
+
if proof && proof.fetch(:body) != response.body
|
|
302
|
+
raise ContractError, 'Raw output digest collision'
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# The fixture still checks this operation's index/status/headers and
|
|
306
|
+
# counters. Only its body-only semantic validator can be reused.
|
|
307
|
+
validated = scenario.validate!(response, index:) do |validate_body|
|
|
308
|
+
proof ? proof.fetch(:validation) : validate_body.call
|
|
309
|
+
end
|
|
310
|
+
identity = validated.transform_keys(&:to_s)
|
|
311
|
+
unless identity.fetch('body_sha256') == raw_digest && identity.fetch('body_bytes') == response.body.bytesize &&
|
|
312
|
+
identity.fetch('http_status') == response.status
|
|
313
|
+
raise ContractError, 'Fixture output identity is incorrect'
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
if proof && validated.equal?(proof.fetch(:validation))
|
|
317
|
+
outputs.fetch(raw_digest)['count'] += 1
|
|
318
|
+
next
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
canonical = identity.delete('canonical_body')
|
|
322
|
+
unless canonical.nil?
|
|
323
|
+
canonical = Identity.canonical(canonical)
|
|
324
|
+
digest = Digest::SHA256.hexdigest(JSON.generate(canonical))
|
|
325
|
+
unless Identity.canonical(Identity.parse(response.body)) == canonical &&
|
|
326
|
+
(!identity.has_key?('canonical_sha256') || identity['canonical_sha256'] == digest)
|
|
327
|
+
raise ContractError, 'Fixture raw/canonical output differs'
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
identity['canonical_sha256'] = digest
|
|
331
|
+
end
|
|
332
|
+
raise ContractError, 'Missing canonical identity' unless identity.has_key?('canonical_sha256')
|
|
333
|
+
|
|
334
|
+
if @request['release_versions'] && scenario.is_a?(DocumentationScenario) &&
|
|
335
|
+
@current_workload.to_s.include?('docs.html_')
|
|
336
|
+
own = File.read(File.join(@request.fetch('source_root'), 'VERSION')).strip
|
|
337
|
+
normalized = Release.html(response.body, own)
|
|
338
|
+
identity['canonical_sha256'] = Digest::SHA256.hexdigest(normalized)
|
|
339
|
+
identity['comparison'] = { 'kind' => 'own-webui-href', 'own_version' => own }
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
if proof && (identity != proof.fetch(:output) || canonical != bodies.fetch(raw_digest).fetch(:canonical_body))
|
|
343
|
+
raise ContractError, 'Fixture body proof changed'
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
bodies[raw_digest] ||= { body: response.body.dup.freeze, canonical_body: Identity.snapshot(canonical) }.freeze
|
|
347
|
+
proofs[raw_digest] ||= { body: bodies.fetch(raw_digest).fetch(:body), validation: Identity.snapshot(validated),
|
|
348
|
+
output: Identity.snapshot(identity) }.freeze
|
|
349
|
+
outputs[raw_digest] ||= proofs.fetch(raw_digest).fetch(:output).merge('count' => 0)
|
|
350
|
+
outputs.fetch(raw_digest)['count'] += 1
|
|
351
|
+
end
|
|
352
|
+
records = scenario.counters.fetch(:operations)
|
|
353
|
+
unless records.length == iterations
|
|
354
|
+
raise ContractError, 'Missing per-operation counters'
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
record = records.first
|
|
358
|
+
unless record.keys.map(&:to_s).sort == COUNTERS.sort && record.values.all? { |value| value.is_a?(Integer) && value >= 0 } &&
|
|
359
|
+
records.all? { |operation| operation == record }
|
|
360
|
+
raise ContractError, 'Operation counters are not uniform and valid'
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
counters = { operations: iterations, record: record.dup }
|
|
364
|
+
|
|
365
|
+
{ operations: iterations, wall_seconds: wall / iterations, cpu_seconds: cpu / iterations,
|
|
366
|
+
allocated_objects: allocations.to_f / iterations, outputs: outputs.values, bodies:, counters:,
|
|
367
|
+
stages: { reset_gc_seconds:, request_seconds: wall, validation_seconds: monotonic - validation_started } }
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def self.fixture_hashes
|
|
371
|
+
paths = FIXTURE_PATHS.map { |name| "servers/ruby/test_support/#{name}" }
|
|
372
|
+
paths += %w[servers/ruby/benchmarks/endpoints.rb servers/ruby/benchmarks/performance_compare.rb]
|
|
373
|
+
paths.to_h { |path| [path, Digest::SHA256.file(File.join(ROOT, path)).hexdigest] }
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
# One notification at each batch boundary, outside measured allocations.
|
|
377
|
+
# A 30s batch watchdog also bounds every individual request in that batch.
|
|
378
|
+
def progress(state, iterations: nil)
|
|
379
|
+
return unless @request['progress_fd']
|
|
380
|
+
|
|
381
|
+
@progress ||= IO.for_fd(@request.fetch('progress_fd'), autoclose: false)
|
|
382
|
+
@progress.syswrite("#{JSON.generate(state:, workload: @current_workload, phase: @phase, iterations:)}\n")
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
def self.inputs(source, profile, release: false, benchmark: false)
|
|
386
|
+
paths = %w[Gemfile flake.lock servers/ruby/Gemfile servers/ruby/haveapi.gemspec clients/ruby/Gemfile clients/ruby/haveapi-client.gemspec]
|
|
387
|
+
paths += %w[clients/rust/Gemfile clients/rust/haveapi-rust-client.gemspec]
|
|
388
|
+
paths += %w[.ruby-version flake.nix] if benchmark
|
|
389
|
+
raw_manifests = paths.to_h do |path|
|
|
390
|
+
target = File.join(source, path)
|
|
391
|
+
raise ContractError, "Missing dependency input #{target}" unless File.file?(target)
|
|
392
|
+
|
|
393
|
+
[path, File.read(target)]
|
|
394
|
+
end
|
|
395
|
+
preparation = Release.json_preparation(raw_manifests)
|
|
396
|
+
manifests = raw_manifests.merge(preparation.fetch('normalized_contents')).transform_values { |text| Digest::SHA256.hexdigest(text) }
|
|
397
|
+
lock_paths = benchmark ? ['servers/ruby/benchmarks/Gemfile.lock'] : %w[Gemfile.lock servers/ruby/Gemfile.lock clients/rust/Gemfile.lock]
|
|
398
|
+
locks = lock_paths.to_h do |path|
|
|
399
|
+
target = File.join(ROOT, path)
|
|
400
|
+
raise ContractError, "Missing resolved lock #{target}" unless File.file?(target)
|
|
401
|
+
|
|
402
|
+
[path, Digest::SHA256.file(target).hexdigest]
|
|
403
|
+
end
|
|
404
|
+
if release
|
|
405
|
+
contents = Release.paths(profile).to_h { |path| [path, File.read(File.join(source, path))] }
|
|
406
|
+
receipt = Release.capture(contents)
|
|
407
|
+
versions = [HaveAPI::VERSION, HaveAPI::Client::VERSION]
|
|
408
|
+
versions << HaveAPI::RustClient::VERSION
|
|
409
|
+
raise ContractError, 'Wrong loaded local package version' unless versions.all? { |version| version == receipt.fetch('own_version') }
|
|
410
|
+
|
|
411
|
+
result = { manifests: receipt.fetch('normalized_sha256'), locks:, release_versions: receipt }
|
|
412
|
+
else
|
|
413
|
+
result = { manifests:, locks:, json_preparation: preparation }
|
|
414
|
+
end
|
|
415
|
+
if benchmark
|
|
416
|
+
result[:manifests] = result.fetch(:manifests).except('flake.nix', 'flake.lock')
|
|
417
|
+
result[:contributor_flakes] = raw_manifests.slice('flake.nix', 'flake.lock').transform_values { |content| { 'content' => content } }
|
|
418
|
+
result[:benchmark] = BENCHMARK_INPUTS.to_h do |path|
|
|
419
|
+
content = File.read(File.join(ROOT, path))
|
|
420
|
+
[path, { 'content' => content, 'sha256' => Digest::SHA256.hexdigest(content) }]
|
|
421
|
+
end
|
|
422
|
+
end
|
|
423
|
+
result
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
def environment
|
|
427
|
+
{ ruby: { engine: RUBY_ENGINE, version: RUBY_VERSION, platform: RUBY_PLATFORM,
|
|
428
|
+
abi: RbConfig::CONFIG.fetch('ruby_version'), executable: RbConfig.ruby },
|
|
429
|
+
gems: Gem.loaded_specs.except('haveapi', 'haveapi-client', 'haveapi-rust-client')
|
|
430
|
+
.sort.to_h.transform_values { |spec| { version: spec.version.to_s, platform: spec.platform.to_s } } }
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
def source_identity(source)
|
|
434
|
+
methods = { 'servers/ruby/lib/haveapi/server.rb' => Server.instance_method(:mount),
|
|
435
|
+
'servers/ruby/lib/haveapi/action.rb' => Action.instance_method(:exec),
|
|
436
|
+
'servers/ruby/lib/haveapi/model_adapters/active_record.rb' => ModelAdapters::ActiveRecord.method(:handle?),
|
|
437
|
+
'clients/ruby/lib/haveapi/client/client.rb' => Client::Client.instance_method(:initialize) }
|
|
438
|
+
if HaveAPI.const_defined?(:RustClient)
|
|
439
|
+
methods['clients/rust/lib/haveapi/rust_client/documentation.rb'] = RustClient::Documentation.instance_method(:render)
|
|
440
|
+
end
|
|
441
|
+
methods.each do |path, method|
|
|
442
|
+
raise ContractError, "Wrong loaded source for #{path}" unless File.identical?(method.source_location.first, File.join(source, path))
|
|
443
|
+
end
|
|
444
|
+
loaded = $".grep(%r{/(?:servers/ruby|clients/ruby|clients/rust)/lib/}).to_h do |path|
|
|
445
|
+
real = File.realpath(path)
|
|
446
|
+
raise ContractError, "Runtime escaped selected source: #{real}" unless real.start_with?("#{source}/")
|
|
447
|
+
|
|
448
|
+
[real.delete_prefix("#{source}/"), Digest::SHA256.file(real).hexdigest]
|
|
449
|
+
end
|
|
450
|
+
{ root: source, sha: @request.fetch('source_sha'), loaded_sources: loaded }
|
|
451
|
+
end
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
class CLI
|
|
455
|
+
EXIT_CODES = { 'pass' => 0, 'regression' => 1, 'inconclusive' => 0, 'error' => 3 }.freeze
|
|
456
|
+
|
|
457
|
+
def initialize(argv)
|
|
458
|
+
@argv = argv.dup
|
|
459
|
+
@mode = @argv.shift
|
|
460
|
+
@options = { profile: 'quick' }
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
def execute
|
|
464
|
+
if @argv == ['--help'] || @mode == '--help'
|
|
465
|
+
puts 'endpoints.rb run --baseline-root PATH --baseline-sha SHA --candidate-root PATH --candidate-sha SHA --profile quick|weekly --budgets FILE --output NEWDIR'
|
|
466
|
+
puts 'endpoints.rb compare --input samples.json --budgets FILE --output NEWDIR'
|
|
467
|
+
return 0
|
|
468
|
+
end
|
|
469
|
+
return worker if @mode == 'worker'
|
|
470
|
+
|
|
471
|
+
comparison = { 'policy' => Comparator::POLICY, 'comparator_sha256' => Comparator::SOURCE_SHA256 }
|
|
472
|
+
parse
|
|
473
|
+
destination = File.expand_path(@options.fetch(:output))
|
|
474
|
+
raise ContractError, 'Output directory must not exist' if File.exist?(destination)
|
|
475
|
+
|
|
476
|
+
@output = destination
|
|
477
|
+
FileUtils.mkdir_p(@output)
|
|
478
|
+
budget_path = @options.fetch(:budgets)
|
|
479
|
+
budgets = Identity.parse(File.read(budget_path))
|
|
480
|
+
digest = Digest::SHA256.file(budget_path).hexdigest
|
|
481
|
+
if @mode == 'run'
|
|
482
|
+
samples = run_samples(digest)
|
|
483
|
+
sample_bytes = JSON.pretty_generate(samples)
|
|
484
|
+
File.binwrite(File.join(@output, 'samples.json'), sample_bytes)
|
|
485
|
+
else
|
|
486
|
+
sample_bytes = File.binread(@options.fetch(:input))
|
|
487
|
+
end
|
|
488
|
+
comparison['samples_sha256'] = Digest::SHA256.hexdigest(sample_bytes)
|
|
489
|
+
samples = Identity.parse(sample_bytes) unless @mode == 'run'
|
|
490
|
+
report = Comparator.new(samples, budgets, budget_sha256: digest).compare.merge('comparison' => comparison)
|
|
491
|
+
File.write(File.join(@output, 'report.json'), JSON.pretty_generate(report))
|
|
492
|
+
puts JSON.generate(status: report.fetch('status'), report: File.join(@output, 'report.json'))
|
|
493
|
+
EXIT_CODES.fetch(report.fetch('status'))
|
|
494
|
+
rescue StandardError, LoadError => e
|
|
495
|
+
report = { schema_version: SAMPLE_SCHEMA, status: 'error', error: { class: e.class.name, message: e.message } }
|
|
496
|
+
report[:error][:details] = e.details if e.is_a?(WorkerTimeout)
|
|
497
|
+
%i[baseline_sha candidate_sha profile].each { |key| report[key] = @options[key] if @options.has_key?(key) }
|
|
498
|
+
if samples.is_a?(Hash)
|
|
499
|
+
report.merge!(samples.slice('baseline_sha', 'candidate_sha', 'profile', 'harness_sha256', 'budget_sha256').transform_keys(&:to_sym))
|
|
500
|
+
end
|
|
501
|
+
report[:comparison] = comparison if comparison
|
|
502
|
+
File.write(File.join(@output, 'report.json'), JSON.pretty_generate(report)) if @output && File.directory?(@output)
|
|
503
|
+
warn JSON.generate(report)
|
|
504
|
+
3
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
private
|
|
508
|
+
|
|
509
|
+
def parse
|
|
510
|
+
raise ContractError, 'Expected run or compare' unless %w[run compare].include?(@mode)
|
|
511
|
+
|
|
512
|
+
OptionParser.new do |parser|
|
|
513
|
+
%w[baseline-root baseline-sha candidate-root candidate-sha profile budgets output input push-event].each do |name|
|
|
514
|
+
parser.on("--#{name} VALUE") { |value| @options[name.tr('-', '_').to_sym] = value }
|
|
515
|
+
end
|
|
516
|
+
end.parse!(@argv)
|
|
517
|
+
raise ContractError, 'Unexpected arguments' unless @argv.empty?
|
|
518
|
+
|
|
519
|
+
PROFILES.fetch(@options.fetch(:profile))
|
|
520
|
+
required = @mode == 'run' ? %i[baseline_root baseline_sha candidate_root candidate_sha budgets output] : %i[input budgets output]
|
|
521
|
+
required.each { |key| @options.fetch(key) }
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
def worker
|
|
525
|
+
raise ContractError, 'Unexpected worker arguments' unless @argv.empty?
|
|
526
|
+
|
|
527
|
+
result = Worker.new(Identity.parse($stdin.read)).run
|
|
528
|
+
serialization_started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
529
|
+
serialized = JSON.generate(result)
|
|
530
|
+
serialization_seconds = Process.clock_gettime(Process::CLOCK_MONOTONIC) - serialization_started
|
|
531
|
+
# Add the serialization receipt without serializing the response trees a
|
|
532
|
+
# second time solely to report the first serialization's duration.
|
|
533
|
+
puts "#{serialized.delete_suffix('}')},\"serialization_seconds\":#{serialization_seconds}}"
|
|
534
|
+
0
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
def git(*args)
|
|
538
|
+
output, status = Open3.capture2('git', '-C', ROOT, *args, err: File::NULL)
|
|
539
|
+
raise ContractError, "Cannot verify source object: git #{args.first}" unless status.success?
|
|
540
|
+
|
|
541
|
+
output
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
def ancestor?(left, right)
|
|
545
|
+
_output, status = Open3.capture2('git', '-C', ROOT, 'merge-base', '--is-ancestor', left, right, err: File::NULL)
|
|
546
|
+
raise ContractError, 'Cannot establish source ancestry' unless [0, 1].include?(status.exitstatus)
|
|
547
|
+
|
|
548
|
+
status.success?
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
def verify_source(role)
|
|
552
|
+
sha = @options.fetch(:"#{role}_sha")
|
|
553
|
+
raise ContractError, 'Source SHA must be full immutable SHA' unless sha.match?(/\A[0-9a-f]{40}\z/) && sha != '0' * 40
|
|
554
|
+
|
|
555
|
+
root = File.realpath(@options.fetch(:"#{role}_root"))
|
|
556
|
+
git('cat-file', '-e', "#{sha}^{commit}")
|
|
557
|
+
files = git('ls-tree', '-rz', '--full-tree', sha).split("\0")
|
|
558
|
+
graph = {}
|
|
559
|
+
files.each do |entry|
|
|
560
|
+
metadata, path = entry.split("\t", 2)
|
|
561
|
+
mode, kind, expected = metadata.split
|
|
562
|
+
raise ContractError, "Unsupported source entry #{path}" unless kind == 'blob'
|
|
563
|
+
|
|
564
|
+
target = File.join(root, path)
|
|
565
|
+
contents = mode == '120000' ? File.readlink(target) : File.binread(target)
|
|
566
|
+
actual = Digest::SHA1.hexdigest("blob #{contents.bytesize}\0#{contents}")
|
|
567
|
+
raise ContractError, "Source tree mismatch #{role}:#{path}" unless actual == expected
|
|
568
|
+
|
|
569
|
+
graph[path] = Digest::SHA256.hexdigest(contents)
|
|
570
|
+
end
|
|
571
|
+
{ root:, sha:, tracked_files: files.length, files_sha256: graph, tree: git('rev-parse', "#{sha}^{tree}").strip }
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
def verify_runtime
|
|
575
|
+
expected = Identity.parse(File.read(@options.fetch(:budgets))).fetch('environment').fetch('weekly')
|
|
576
|
+
ruby = JSON.parse(JSON.generate(Worker.new({}).environment.fetch(:ruby)))
|
|
577
|
+
raise ContractError, 'Calibration Ruby mismatch' unless ruby == expected.fetch('ruby')
|
|
578
|
+
|
|
579
|
+
expected.fetch('gems').each do |name, identity|
|
|
580
|
+
unless Gem::Specification.find_all_by_name(name, identity.fetch('version')).any? { |spec| spec.platform.to_s == identity.fetch('platform') }
|
|
581
|
+
raise ContractError, "Missing calibrated dependency #{name}"
|
|
582
|
+
end
|
|
583
|
+
end
|
|
584
|
+
end
|
|
585
|
+
|
|
586
|
+
def run_samples(digest)
|
|
587
|
+
policy = PROFILES.fetch(@options.fetch(:profile))
|
|
588
|
+
verify_runtime
|
|
589
|
+
Performance.validate_source_pair(profile: @options[:profile], baseline_sha: @options[:baseline_sha],
|
|
590
|
+
candidate_sha: @options[:candidate_sha])
|
|
591
|
+
|
|
592
|
+
sources = %w[baseline candidate].to_h { |role| [role, verify_source(role)] }
|
|
593
|
+
@sources = JSON.parse(JSON.generate(sources))
|
|
594
|
+
@release_lineage = Release.lineage(git: method(:git), ancestor: method(:ancestor?), candidate: @options[:candidate_sha])
|
|
595
|
+
if @options[:push_event]
|
|
596
|
+
raise ContractError, 'Push event exception requires quick profile' unless @options[:profile] == 'quick'
|
|
597
|
+
|
|
598
|
+
push = Performance.push_pair!(Identity.parse(File.read(@options[:push_event])), baseline: @options[:baseline_sha], candidate: @options[:candidate_sha])
|
|
599
|
+
raise ContractError, 'Missing exact push pair' unless push
|
|
600
|
+
end
|
|
601
|
+
unless push || ancestor?(@options[:baseline_sha], @options[:candidate_sha]) ||
|
|
602
|
+
(@options[:profile] == 'weekly' && @options[:baseline_sha] == REFERENCE && @release_lineage)
|
|
603
|
+
raise ContractError, 'Baseline must be an ancestor of candidate'
|
|
604
|
+
end
|
|
605
|
+
|
|
606
|
+
@sources.each_value do |source|
|
|
607
|
+
Release::VERSIONS.each do |path, name|
|
|
608
|
+
Performance.gemspec(source.fetch('root'), "#{path.split('/lib/').first}/#{name}.gemspec")
|
|
609
|
+
end
|
|
610
|
+
end
|
|
611
|
+
|
|
612
|
+
pilot = child('baseline', {}, 'pilot')
|
|
613
|
+
inventory = pilot.fetch('inventory')
|
|
614
|
+
counts = pilot.fetch('cases').to_h do |item|
|
|
615
|
+
count = (policy.fetch(:target) / item.fetch('wall_seconds')).ceil.clamp(1, MAX_ITERATIONS)
|
|
616
|
+
[Identity.key(item), count]
|
|
617
|
+
end
|
|
618
|
+
warmup_counts = pilot.fetch('cases').to_h do |item|
|
|
619
|
+
count = item.fetch('wall_seconds') >= policy.fetch(:target) ? 0 : (0.100 / item.fetch('wall_seconds')).ceil.clamp(1, counts.fetch(Identity.key(item)))
|
|
620
|
+
[Identity.key(item), count]
|
|
621
|
+
end
|
|
622
|
+
@warmup_counts = warmup_counts
|
|
623
|
+
calibration = child('baseline', counts, 'calibration')
|
|
624
|
+
adjusted = false
|
|
625
|
+
calibration.fetch('cases').each do |item|
|
|
626
|
+
key = Identity.key(item)
|
|
627
|
+
duration = item.fetch('wall_seconds') * counts.fetch(key)
|
|
628
|
+
next if warmup_counts.fetch(key) == 0 || duration >= policy.fetch(:adequate) || counts.fetch(key) == MAX_ITERATIONS
|
|
629
|
+
|
|
630
|
+
counts[key] = (counts.fetch(key) * policy.fetch(:target) / duration).ceil.clamp(1, MAX_ITERATIONS)
|
|
631
|
+
adjusted = true
|
|
632
|
+
end
|
|
633
|
+
calibration = child('baseline', counts, 'calibration-adjusted') if adjusted
|
|
634
|
+
cases = inventory.map do |spec|
|
|
635
|
+
observed = calibration.fetch('cases').find { |item| Identity.key(item) == Identity.key(spec) }
|
|
636
|
+
count = counts.fetch(Identity.key(spec))
|
|
637
|
+
duration = observed.fetch('wall_seconds') * count
|
|
638
|
+
spec.merge('iterations' => count, 'pilot' => observed.merge('selected_iterations' => count,
|
|
639
|
+
'warmup_iterations' => warmup_counts.fetch(Identity.key(spec)), 'target_seconds' => policy.fetch(:target),
|
|
640
|
+
'baseline_batch_seconds' => duration, 'adequate_signal' => duration >= policy.fetch(:adequate),
|
|
641
|
+
'capped' => count == MAX_ITERATIONS, 'calibration_adjusted' => adjusted), 'self_pairs' => [], 'pairs' => [])
|
|
642
|
+
end
|
|
643
|
+
policy[:self_pairs].times do |index|
|
|
644
|
+
workload_order = index.even? ? 'forward' : 'reverse'
|
|
645
|
+
left = child('baseline', counts, "aa-#{index}-a", workload_order)
|
|
646
|
+
right = child('baseline', counts, "aa-#{index}-b", workload_order)
|
|
647
|
+
append_pair(cases, 'self_pairs', %w[baseline baseline], left, right, workload_order)
|
|
648
|
+
end
|
|
649
|
+
policy[:pairs].times do |index|
|
|
650
|
+
order = index.even? ? %w[baseline candidate] : %w[candidate baseline]
|
|
651
|
+
workload_order = index.even? ? 'forward' : 'reverse'
|
|
652
|
+
observations = order.map { |role| child(role, counts, "ab-#{index}-#{role}", workload_order) }
|
|
653
|
+
baseline = observations.fetch(order.index('baseline'))
|
|
654
|
+
candidate = observations.fetch(order.index('candidate'))
|
|
655
|
+
append_pair(cases, 'pairs', order, baseline, candidate, workload_order)
|
|
656
|
+
end
|
|
657
|
+
{ 'schema_version' => SAMPLE_SCHEMA, 'profile' => @options[:profile], 'baseline_sha' => @options[:baseline_sha],
|
|
658
|
+
'candidate_sha' => @options[:candidate_sha], 'harness_sha256' => Identity.digest(Worker.fixture_hashes),
|
|
659
|
+
'fixture_sha256' => Worker.fixture_hashes, 'budget_sha256' => digest, 'sources' => @sources,
|
|
660
|
+
'environment' => @common.fetch('environment'), 'inputs' => @common.fetch('inputs'), 'release_lineage' => @release_lineage,
|
|
661
|
+
'observations' => @observations,
|
|
662
|
+
'inventory' => inventory, 'cases' => cases }
|
|
663
|
+
end
|
|
664
|
+
|
|
665
|
+
def append_pair(cases, name, order, left, right, workload_order)
|
|
666
|
+
cases.each do |item|
|
|
667
|
+
key = Identity.key(item)
|
|
668
|
+
a = left.fetch('cases').find { |entry| Identity.key(entry) == key }
|
|
669
|
+
b = right.fetch('cases').find { |entry| Identity.key(entry) == key }
|
|
670
|
+
raise ContractError, 'Worker omitted a case' unless a && b
|
|
671
|
+
|
|
672
|
+
item[name] << { 'order' => order, 'workload_order' => workload_order, 'baseline' => a, 'candidate' => b }
|
|
673
|
+
end
|
|
674
|
+
end
|
|
675
|
+
|
|
676
|
+
def child(role, counts, label, workload_order = 'forward')
|
|
677
|
+
request = { source_root: File.realpath(@options.fetch(:"#{role}_root")), source_sha: @options.fetch(:"#{role}_sha"),
|
|
678
|
+
profile: @options[:profile], workload_order:, counts:, warmup_counts: @warmup_counts || {}, release_versions: !@release_lineage.nil?, benchmark: true }
|
|
679
|
+
env = ENV.keys.grep(/\ABUNDLE|\ABUNDLER|\ARUBY(?:OPT|LIB|GEMS_GEMDEPS)\z/).to_h { |key| [key, nil] }
|
|
680
|
+
env.merge!('LC_ALL' => 'C.UTF-8', 'LANG' => 'C.UTF-8', 'TZ' => 'UTC', 'RUBY_YJIT_ENABLE' => '0', 'RACK_ENV' => 'test')
|
|
681
|
+
process_started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
682
|
+
output, errors, status = capture_worker(env, request, label)
|
|
683
|
+
process_wall_seconds = Process.clock_gettime(Process::CLOCK_MONOTONIC) - process_started
|
|
684
|
+
File.write(File.join(@output, "#{label}.stderr.log"), errors)
|
|
685
|
+
File.write(File.join(@output, "#{label}.json"), output)
|
|
686
|
+
raise ContractError, "Worker failed #{label} (#{status.exitstatus}): #{errors.lines.last}" unless status.success?
|
|
687
|
+
|
|
688
|
+
result = JSON.parse(output)
|
|
689
|
+
raise ContractError, 'Unsupported worker sample schema' unless result.fetch('schema_version').eql?(SAMPLE_SCHEMA)
|
|
690
|
+
raise ContractError, 'Worker source identity mismatch' unless result.fetch('source').fetch('root') == request[:source_root] && result['source']['sha'] == request[:source_sha]
|
|
691
|
+
raise ContractError, 'Worker fixture graph mismatch' unless result.fetch('fixture_sha256') == Worker.fixture_hashes
|
|
692
|
+
|
|
693
|
+
result.fetch('source').fetch('loaded_sources').each do |path, sha256|
|
|
694
|
+
unless @sources.fetch(role).fetch('files_sha256').fetch(path) == sha256
|
|
695
|
+
raise ContractError, 'Loaded runtime differs from exact source object'
|
|
696
|
+
end
|
|
697
|
+
end
|
|
698
|
+
|
|
699
|
+
inputs = result.fetch('inputs')
|
|
700
|
+
if @release_lineage
|
|
701
|
+
Release.validate_inputs(inputs, @sources.fetch(role), @options.fetch(:profile))
|
|
702
|
+
elsif inputs.has_key?('release_versions')
|
|
703
|
+
raise ContractError, 'Unproven own-version normalization'
|
|
704
|
+
else
|
|
705
|
+
Release.validate_json_preparation(inputs, @sources.fetch(role))
|
|
706
|
+
end
|
|
707
|
+
expected = Identity.parse(File.read(@options.fetch(:budgets))).fetch('environment').fetch('weekly')
|
|
708
|
+
raise ContractError, 'Calibration environment mismatch' unless result.fetch('environment') == expected
|
|
709
|
+
|
|
710
|
+
common = result.slice('environment', 'inventory', 'fixture_sha256').merge('inputs' => Release.graph(inputs))
|
|
711
|
+
@common ||= common
|
|
712
|
+
raise ContractError, 'Incompatible dependency/runtime/input/workload graph' unless common == @common
|
|
713
|
+
|
|
714
|
+
@observations ||= {}
|
|
715
|
+
@observations[label] = result.slice('source', 'environment', 'inputs', 'fixture_sha256', 'runtime_policy', 'host',
|
|
716
|
+
'workload_order', 'stages', 'serialization_seconds').merge('process_wall_seconds' => process_wall_seconds)
|
|
717
|
+
result.fetch('cases').each { |item| item['observation'] = label }
|
|
718
|
+
result
|
|
719
|
+
end
|
|
720
|
+
|
|
721
|
+
def worker_command
|
|
722
|
+
[RbConfig.ruby, '--disable-yjit', __FILE__, 'worker']
|
|
723
|
+
end
|
|
724
|
+
|
|
725
|
+
def capture_worker(env, request, label)
|
|
726
|
+
reader, writer = IO.pipe
|
|
727
|
+
request = request.merge(progress_fd: writer.fileno)
|
|
728
|
+
output = errors = ''
|
|
729
|
+
status = nil
|
|
730
|
+
Open3.popen3(env, *worker_command, writer.fileno => writer, pgroup: true) do |input, stdout, stderr, waiter|
|
|
731
|
+
writer.close
|
|
732
|
+
out_thread = Thread.new { stdout.read }
|
|
733
|
+
err_thread = Thread.new { stderr.read }
|
|
734
|
+
begin
|
|
735
|
+
input.write(JSON.generate(request))
|
|
736
|
+
input.close
|
|
737
|
+
event = { 'state' => 'setup' }
|
|
738
|
+
File.open(File.join(@output, "#{label}.progress.jsonl"), 'w') do |log|
|
|
739
|
+
loop do
|
|
740
|
+
limit = event.fetch('state') == 'request' ? REQUEST_LIMIT_SECONDS : SETUP_LIMIT_SECONDS
|
|
741
|
+
unless reader.wait_readable(limit)
|
|
742
|
+
raise WorkerTimeout.new(kind: event['state'] == 'request' ? 'request_batch_timeout' : 'worker_setup_timeout',
|
|
743
|
+
source_sha: request.fetch(:source_sha), workload: event['workload'],
|
|
744
|
+
phase: "#{label}/#{event['phase']}", limit_seconds: limit)
|
|
745
|
+
end
|
|
746
|
+
line = reader.gets
|
|
747
|
+
break unless line
|
|
748
|
+
|
|
749
|
+
log.write(line)
|
|
750
|
+
log.flush
|
|
751
|
+
event = JSON.parse(line)
|
|
752
|
+
raise ContractError, 'Invalid worker progress' unless %w[request setup].include?(event.fetch('state'))
|
|
753
|
+
end
|
|
754
|
+
end
|
|
755
|
+
unless waiter.join(SETUP_LIMIT_SECONDS)
|
|
756
|
+
raise WorkerTimeout.new(kind: 'worker_setup_timeout', source_sha: request.fetch(:source_sha),
|
|
757
|
+
workload: event['workload'], phase: "#{label}/serialization", limit_seconds: SETUP_LIMIT_SECONDS)
|
|
758
|
+
end
|
|
759
|
+
status = waiter.value
|
|
760
|
+
ensure
|
|
761
|
+
unless waiter.join(0)
|
|
762
|
+
begin
|
|
763
|
+
Process.kill('TERM', -waiter.pid)
|
|
764
|
+
waiter.join(0.2)
|
|
765
|
+
Process.kill('KILL', -waiter.pid)
|
|
766
|
+
rescue Errno::ESRCH
|
|
767
|
+
# The owned process group has exited.
|
|
768
|
+
end
|
|
769
|
+
waiter.value
|
|
770
|
+
end
|
|
771
|
+
output = out_thread.value
|
|
772
|
+
errors = err_thread.value
|
|
773
|
+
File.write(File.join(@output, "#{label}.stderr.log"), errors)
|
|
774
|
+
File.write(File.join(@output, "#{label}.json"), output)
|
|
775
|
+
end
|
|
776
|
+
end
|
|
777
|
+
[output, errors, status]
|
|
778
|
+
ensure
|
|
779
|
+
reader&.close unless reader&.closed?
|
|
780
|
+
writer&.close unless writer&.closed?
|
|
781
|
+
end
|
|
782
|
+
end
|
|
783
|
+
end
|
|
784
|
+
end
|
|
785
|
+
|
|
786
|
+
exit HaveAPI::Performance::CLI.new(ARGV).execute if $0 == __FILE__
|