perfgate 0.1.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 +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +25 -0
- data/CHANGELOG.md +18 -0
- data/CONTRIBUTING.md +73 -0
- data/LICENSE +201 -0
- data/NOT_FINALIZED.md +70 -0
- data/README.md +86 -0
- data/ROADMAP.md +125 -0
- data/Rakefile +12 -0
- data/SECURITY.md +63 -0
- data/docs/README.md +7 -0
- data/docs/architecture.md +125 -0
- data/docs/compatibility.md +49 -0
- data/docs/launch-article.md +97 -0
- data/docs/onboarding.md +122 -0
- data/docs/telemetry.md +81 -0
- data/examples/rails-rspec-app/.github/workflows/baseline.yml +48 -0
- data/examples/rails-rspec-app/README.md +38 -0
- data/examples/rails-rspec-app/spec/jobs/invoice_job_spec.rb +15 -0
- data/examples/rails-rspec-app/spec/requests/checkout_spec.rb +24 -0
- data/exe/perfgate +7 -0
- data/lib/perfgate/cli/compare_command.rb +91 -0
- data/lib/perfgate/cli/run_command.rb +123 -0
- data/lib/perfgate/cli/run_comparison_reporter.rb +77 -0
- data/lib/perfgate/cli.rb +60 -0
- data/lib/perfgate/comparison/deterministic_metric_decision.rb +34 -0
- data/lib/perfgate/comparison/diagnostics.rb +70 -0
- data/lib/perfgate/comparison/engine.rb +79 -0
- data/lib/perfgate/comparison/metric_change.rb +84 -0
- data/lib/perfgate/comparison/metric_decision.rb +39 -0
- data/lib/perfgate/comparison/statistical_metric_decision.rb +75 -0
- data/lib/perfgate/comparison/workload_comparison.rb +98 -0
- data/lib/perfgate/config/defaults.rb +70 -0
- data/lib/perfgate/config/env_overrides.rb +54 -0
- data/lib/perfgate/config/schema.rb +53 -0
- data/lib/perfgate/config/validator.rb +64 -0
- data/lib/perfgate/config.rb +136 -0
- data/lib/perfgate/errors.rb +20 -0
- data/lib/perfgate/execution/process_runner.rb +71 -0
- data/lib/perfgate/execution/runner.rb +60 -0
- data/lib/perfgate/execution/sample_context.rb +66 -0
- data/lib/perfgate/fingerprints/compatibility.rb +46 -0
- data/lib/perfgate/fingerprints/components.rb +98 -0
- data/lib/perfgate/fingerprints/workload_definition.rb +30 -0
- data/lib/perfgate/instrumentation/allocations.rb +20 -0
- data/lib/perfgate/instrumentation/duration.rb +20 -0
- data/lib/perfgate/instrumentation/gc.rb +30 -0
- data/lib/perfgate/instrumentation/sql_activity.rb +50 -0
- data/lib/perfgate/instrumentation.rb +36 -0
- data/lib/perfgate/metrics/.gitkeep +0 -0
- data/lib/perfgate/policy/engine.rb +73 -0
- data/lib/perfgate/rails/.gitkeep +0 -0
- data/lib/perfgate/report/console.rb +57 -0
- data/lib/perfgate/report/markdown.rb +101 -0
- data/lib/perfgate/reporting/.gitkeep +0 -0
- data/lib/perfgate/rspec/discovery.rb +31 -0
- data/lib/perfgate/rspec/id_resolver.rb +30 -0
- data/lib/perfgate/rspec/workload_builder.rb +48 -0
- data/lib/perfgate/rspec.rb +14 -0
- data/lib/perfgate/serialization/run_result.rb +58 -0
- data/lib/perfgate/statistics/mann_whitney_u.rb +84 -0
- data/lib/perfgate/statistics/summary.rb +60 -0
- data/lib/perfgate/storage/adapter.rb +24 -0
- data/lib/perfgate/storage/archive.rb +69 -0
- data/lib/perfgate/storage/filesystem.rb +121 -0
- data/lib/perfgate/telemetry/.gitkeep +0 -0
- data/lib/perfgate/version.rb +5 -0
- data/lib/perfgate/workloads/registry.rb +50 -0
- data/lib/perfgate/workloads/workload.rb +26 -0
- data/lib/perfgate.rb +46 -0
- data/perfgate.gemspec +41 -0
- data/schemas/comparison-result-v1.schema.json +7 -0
- data/schemas/run-result-v1.schema.json +7 -0
- data/sig/perfgate.rbs +4 -0
- metadata +139 -0
data/exe/perfgate
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
require_relative "../config"
|
|
5
|
+
require_relative "../comparison/engine"
|
|
6
|
+
require_relative "../policy/engine"
|
|
7
|
+
require_relative "../storage/filesystem"
|
|
8
|
+
require_relative "../report/console"
|
|
9
|
+
require_relative "../report/markdown"
|
|
10
|
+
|
|
11
|
+
module Perfgate
|
|
12
|
+
class CLI
|
|
13
|
+
# Implements `baseline compare` (spec section 10.3): loads two
|
|
14
|
+
# already-produced result bundles, runs them through the comparison
|
|
15
|
+
# engine and policy engine, saves the comparison document, prints a
|
|
16
|
+
# console (or Markdown, with --format markdown) report, and exits
|
|
17
|
+
# with the CI exit code from spec section 17.
|
|
18
|
+
class CompareCommand
|
|
19
|
+
def initialize(argv)
|
|
20
|
+
@argv = argv.dup
|
|
21
|
+
@options = { config: "perfgate.yml" }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def call
|
|
25
|
+
parse_options!
|
|
26
|
+
require_bundle_options!
|
|
27
|
+
config = load_configuration
|
|
28
|
+
|
|
29
|
+
comparison_result = compare(config)
|
|
30
|
+
policy_result = Policy::Engine.evaluate(comparison_result: comparison_result, config: config)
|
|
31
|
+
|
|
32
|
+
comparison_path = save(config, comparison_result)
|
|
33
|
+
report(comparison_result, policy_result, comparison_path)
|
|
34
|
+
policy_result.fetch("exit_code")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def compare(config)
|
|
40
|
+
baseline_run = load_bundle(@options[:baseline])
|
|
41
|
+
candidate_run = load_bundle(@options[:candidate])
|
|
42
|
+
|
|
43
|
+
Comparison::Engine.compare(baseline_run: baseline_run, candidate_run: candidate_run, config: config)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def require_bundle_options!
|
|
47
|
+
return if @options[:baseline] && @options[:candidate]
|
|
48
|
+
|
|
49
|
+
raise Perfgate::ConfigurationError, "baseline compare requires both --baseline and --candidate"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def load_configuration
|
|
53
|
+
config = Perfgate::Config.load(@options[:config])
|
|
54
|
+
Perfgate.configuration = config
|
|
55
|
+
config
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def load_bundle(reference)
|
|
59
|
+
Storage::Filesystem.new(root: File.dirname(reference)).load_run(reference)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def save(config, comparison_result)
|
|
63
|
+
Storage::Filesystem.new(root: @options[:output] || config.storage_path).save_comparison(comparison_result)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def parse_options!
|
|
67
|
+
option_parser.parse!(@argv)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def option_parser
|
|
71
|
+
OptionParser.new do |opts|
|
|
72
|
+
opts.on("--baseline PATH") { |v| @options[:baseline] = v }
|
|
73
|
+
opts.on("--candidate PATH") { |v| @options[:candidate] = v }
|
|
74
|
+
opts.on("--config PATH") { |v| @options[:config] = v }
|
|
75
|
+
opts.on("--output PATH") { |v| @options[:output] = v }
|
|
76
|
+
opts.on("--format FORMAT") { |v| @options[:format] = v }
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def report(comparison_result, policy_result, comparison_path)
|
|
81
|
+
if @options[:format] == "markdown"
|
|
82
|
+
puts Report::Markdown.render(comparison_result: comparison_result, policy_result: policy_result,
|
|
83
|
+
comparison_path: comparison_path)
|
|
84
|
+
else
|
|
85
|
+
puts Report::Console.render(comparison_result: comparison_result, policy_result: policy_result)
|
|
86
|
+
puts "Machine-readable result: #{comparison_path}"
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
require_relative "../config"
|
|
5
|
+
require_relative "../execution/process_runner"
|
|
6
|
+
require_relative "../serialization/run_result"
|
|
7
|
+
require_relative "../storage/filesystem"
|
|
8
|
+
require_relative "run_comparison_reporter"
|
|
9
|
+
|
|
10
|
+
module Perfgate
|
|
11
|
+
class CLI
|
|
12
|
+
# Implements `baseline run` (spec section 10.2): discovers workloads
|
|
13
|
+
# via the RSpec integration, executes each workload's warmup+samples
|
|
14
|
+
# in an isolated child process, and writes a filesystem result
|
|
15
|
+
# bundle. With --compare PATH, immediately compares the fresh run
|
|
16
|
+
# against a reference bundle and reports/exits like `baseline
|
|
17
|
+
# compare` would -- this is the single combined step the spec's
|
|
18
|
+
# GitHub Actions example (section 19.1) invokes. --format markdown
|
|
19
|
+
# additionally writes a summary.md into the output directory for a
|
|
20
|
+
# GitHub job summary.
|
|
21
|
+
class RunCommand
|
|
22
|
+
def initialize(argv)
|
|
23
|
+
@argv = argv.dup
|
|
24
|
+
@options = { config: "perfgate.yml" }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def call
|
|
28
|
+
parse_options!
|
|
29
|
+
config = load_configuration
|
|
30
|
+
run_result = execute(config)
|
|
31
|
+
run_dir = save(config, run_result)
|
|
32
|
+
|
|
33
|
+
@options[:compare] ? compare_and_report(config, run_result, run_dir) : run_only_report(run_result, run_dir)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def load_configuration
|
|
39
|
+
config = Perfgate::Config.load(@options[:config])
|
|
40
|
+
Perfgate.configuration = config
|
|
41
|
+
config
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def execute(config)
|
|
45
|
+
require "perfgate/rspec"
|
|
46
|
+
load_spec_files
|
|
47
|
+
Perfgate::RSpec::Discovery.call
|
|
48
|
+
|
|
49
|
+
results = Perfgate.registry.map { |workload| Execution::ProcessRunner.new(workload).call }
|
|
50
|
+
Serialization::RunResult.build(results, config: config)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def save(config, run_result)
|
|
54
|
+
Storage::Filesystem.new(root: @options[:output] || config.storage_path).save_run(run_result)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def parse_options!
|
|
58
|
+
option_parser.parse!(@argv)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def option_parser # rubocop:disable Metrics/AbcSize
|
|
62
|
+
OptionParser.new do |opts|
|
|
63
|
+
opts.on("--config PATH") { |v| @options[:config] = v }
|
|
64
|
+
opts.on("--output PATH") { |v| @options[:output] = v }
|
|
65
|
+
opts.on("--only PATTERN") { |v| @options[:only] = v }
|
|
66
|
+
opts.on("--format FORMAT") { |v| @options[:format] = v }
|
|
67
|
+
opts.on("--fail-on MODE") { |v| @options[:fail_on] = v }
|
|
68
|
+
opts.on("--seed N", Integer) { |v| @options[:seed] = v }
|
|
69
|
+
opts.on("--profile NAME") { |v| @options[:profile] = v }
|
|
70
|
+
opts.on("--compare PATH") { |v| @options[:compare] = v }
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def load_spec_files
|
|
75
|
+
options = ::RSpec::Core::ConfigurationOptions.new(spec_paths)
|
|
76
|
+
options.configure(::RSpec.configuration)
|
|
77
|
+
::RSpec.configuration.load_spec_files
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def spec_paths
|
|
81
|
+
@argv.empty? ? ["spec"] : @argv
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def run_only_report(run_result, run_dir)
|
|
85
|
+
puts "baseline run: #{run_result["workloads"].size} workload(s) -> #{run_dir}"
|
|
86
|
+
run_result["workloads"].each { |workload| report_workload(workload) }
|
|
87
|
+
write_run_only_summary(run_result, run_dir)
|
|
88
|
+
exit_code(run_result)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def report_workload(workload)
|
|
92
|
+
summary = workload.dig("summary", "duration_ns")
|
|
93
|
+
if workload["status"] == "completed" && summary
|
|
94
|
+
median_ms = summary["median"] / 1_000_000.0
|
|
95
|
+
puts format(" %<id>-40s median=%<median>.2fms (n=%<count>d)",
|
|
96
|
+
id: workload["id"], median: median_ms, count: workload["samples"].size)
|
|
97
|
+
else
|
|
98
|
+
puts " #{workload["id"]}: #{workload["status"]} (#{workload["error"]})"
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def exit_code(run_result)
|
|
103
|
+
run_result["workloads"].any? { |w| w["status"] == "error" } ? 1 : 0
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def write_run_only_summary(run_result, run_dir)
|
|
107
|
+
return unless @options[:format] == "markdown"
|
|
108
|
+
|
|
109
|
+
rows = run_result["workloads"].map { |w| "| #{w["id"]} | #{w["status"]} |" }
|
|
110
|
+
content = (["## Baseline Run", "", "No reference bundle was compared against.", "",
|
|
111
|
+
"| Workload | Status |", "|---|---|"] + rows).join("\n")
|
|
112
|
+
File.write(File.join(run_dir, "summary.md"), content)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def compare_and_report(config, run_result, run_dir)
|
|
116
|
+
reporter = RunComparisonReporter.new(reference_path: @options[:compare],
|
|
117
|
+
output_root: @options[:output] || config.storage_path,
|
|
118
|
+
format: @options[:format])
|
|
119
|
+
reporter.call(config: config, run_result: run_result, run_dir: run_dir)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../comparison/engine"
|
|
4
|
+
require_relative "../policy/engine"
|
|
5
|
+
require_relative "../report/console"
|
|
6
|
+
require_relative "../report/markdown"
|
|
7
|
+
require_relative "../storage/filesystem"
|
|
8
|
+
|
|
9
|
+
module Perfgate
|
|
10
|
+
class CLI
|
|
11
|
+
# Handles `baseline run`'s optional --compare PATH step: loads the
|
|
12
|
+
# reference bundle (tolerating a missing one, since the spec's
|
|
13
|
+
# GitHub Actions example downloads it with continue-on-error),
|
|
14
|
+
# compares it against the freshly-run result, saves and reports the
|
|
15
|
+
# comparison, and returns the CI exit code. Split out of RunCommand
|
|
16
|
+
# to keep both classes under RuboCop's length limits.
|
|
17
|
+
class RunComparisonReporter
|
|
18
|
+
def initialize(reference_path:, output_root:, format:)
|
|
19
|
+
@reference_path = reference_path
|
|
20
|
+
@output_root = output_root
|
|
21
|
+
@format = format
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def call(config:, run_result:, run_dir:)
|
|
25
|
+
reference_run = load_reference
|
|
26
|
+
return missing_baseline_report(config, run_dir) unless reference_run
|
|
27
|
+
|
|
28
|
+
comparison_result = Comparison::Engine.compare(baseline_run: reference_run, candidate_run: run_result,
|
|
29
|
+
config: config)
|
|
30
|
+
report_comparison(config, comparison_result, run_dir)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def load_reference
|
|
36
|
+
Storage::Filesystem.new(root: File.dirname(@reference_path)).load_run(@reference_path)
|
|
37
|
+
rescue Perfgate::ResultBundleError
|
|
38
|
+
nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def missing_baseline_report(config, run_dir)
|
|
42
|
+
policy_result = Policy::Engine.evaluate_missing_baseline(config: config)
|
|
43
|
+
puts "baseline run: no baseline found at #{@reference_path} -> #{policy_result["status"]}"
|
|
44
|
+
write_summary(run_dir, "## Baseline Run\n\nNo baseline was found at `#{@reference_path}`.") if markdown?
|
|
45
|
+
policy_result.fetch("exit_code")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def report_comparison(config, comparison_result, run_dir)
|
|
49
|
+
policy_result = Policy::Engine.evaluate(comparison_result: comparison_result, config: config)
|
|
50
|
+
comparison_path = Storage::Filesystem.new(root: @output_root).save_comparison(comparison_result)
|
|
51
|
+
|
|
52
|
+
render(comparison_result, policy_result, comparison_path, run_dir)
|
|
53
|
+
policy_result.fetch("exit_code")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def render(comparison_result, policy_result, comparison_path, run_dir)
|
|
57
|
+
markdown = Report::Markdown.render(comparison_result: comparison_result, policy_result: policy_result,
|
|
58
|
+
comparison_path: comparison_path)
|
|
59
|
+
if markdown?
|
|
60
|
+
puts markdown
|
|
61
|
+
write_summary(run_dir, markdown)
|
|
62
|
+
else
|
|
63
|
+
puts Report::Console.render(comparison_result: comparison_result, policy_result: policy_result)
|
|
64
|
+
puts "Machine-readable result: #{comparison_path}"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def markdown?
|
|
69
|
+
@format == "markdown"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def write_summary(run_dir, content)
|
|
73
|
+
File.write(File.join(run_dir, "summary.md"), content)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
data/lib/perfgate/cli.rb
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Perfgate
|
|
4
|
+
# Entry point for the `baseline` executable. `run` and `compare` are
|
|
5
|
+
# implemented (Milestones 1-4); `init`, `report`, `doctor`, and
|
|
6
|
+
# `schema` are planned for later milestones (spec section 10).
|
|
7
|
+
class CLI
|
|
8
|
+
def self.start(argv)
|
|
9
|
+
new(argv).run
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def initialize(argv)
|
|
13
|
+
@argv = argv
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def run
|
|
17
|
+
dispatch(*@argv)
|
|
18
|
+
rescue Perfgate::ConfigurationError => e
|
|
19
|
+
warn "baseline: #{e.message}"
|
|
20
|
+
2
|
|
21
|
+
rescue Perfgate::ResultBundleError, Perfgate::WorkloadError => e
|
|
22
|
+
warn "baseline: #{e.message}"
|
|
23
|
+
3
|
|
24
|
+
rescue Perfgate::Error => e
|
|
25
|
+
warn "baseline: #{e.message}"
|
|
26
|
+
1
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def dispatch(command = nil, *rest)
|
|
32
|
+
case command
|
|
33
|
+
when "run" then run_run_command(rest)
|
|
34
|
+
when "compare" then run_compare_command(rest)
|
|
35
|
+
when nil then usage
|
|
36
|
+
else unknown_command(command)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def run_run_command(rest)
|
|
41
|
+
require_relative "cli/run_command"
|
|
42
|
+
RunCommand.new(rest).call
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def run_compare_command(rest)
|
|
46
|
+
require_relative "cli/compare_command"
|
|
47
|
+
CompareCommand.new(rest).call
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def usage
|
|
51
|
+
warn "usage: baseline <command> [options]"
|
|
52
|
+
1
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def unknown_command(command)
|
|
56
|
+
warn "baseline: unknown or not-yet-implemented command #{command.inspect}"
|
|
57
|
+
1
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "metric_change"
|
|
4
|
+
|
|
5
|
+
module Perfgate
|
|
6
|
+
module Comparison
|
|
7
|
+
# Deterministic decision path used for count-like metrics (currently
|
|
8
|
+
# only sql_count): no statistical test is applied, since query
|
|
9
|
+
# counts don't carry the same run-to-run noise that timing and
|
|
10
|
+
# allocation metrics do (spec section 16.3).
|
|
11
|
+
module DeterministicMetricDecision
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def call(metric, baseline_samples, candidate_samples, config)
|
|
15
|
+
change = MetricChange.summarize(metric, baseline_samples, candidate_samples)
|
|
16
|
+
thresholds = config.dig(:comparison, :practical_thresholds, metric.to_sym) || {}
|
|
17
|
+
decision = verdict(change, thresholds)
|
|
18
|
+
|
|
19
|
+
MetricChange.result(change, confidence: nil, practically_significant: decision != "pass", noisy: false,
|
|
20
|
+
decision: decision)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def verdict(change, thresholds)
|
|
24
|
+
warning_absolute = thresholds[:warning_absolute] || 0
|
|
25
|
+
failure_percent = thresholds[:failure_percent] || Float::INFINITY
|
|
26
|
+
|
|
27
|
+
return "pass" if change[:absolute_change] <= 0
|
|
28
|
+
return "pass" if change[:absolute_change] < warning_absolute
|
|
29
|
+
|
|
30
|
+
change[:change_percent] >= failure_percent ? "fail" : "warn"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Perfgate
|
|
4
|
+
module Comparison
|
|
5
|
+
# Simple, deterministic diagnostic rules (spec 20.3). These exist to
|
|
6
|
+
# point a reader at a likely explanation for a regression, never to
|
|
7
|
+
# claim a source-code root cause: "SQL query count increased by 5"
|
|
8
|
+
# is a fact about the sample data, not a guess about which line of
|
|
9
|
+
# code changed.
|
|
10
|
+
module Diagnostics
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def for_workload(metrics)
|
|
14
|
+
[
|
|
15
|
+
sql_count_rule(metrics),
|
|
16
|
+
sql_duration_without_count_rule(metrics),
|
|
17
|
+
allocations_rule(metrics),
|
|
18
|
+
duration_without_resource_change_rule(metrics),
|
|
19
|
+
*noise_rules(metrics)
|
|
20
|
+
].compact
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def environment_changed_rules(compatibility)
|
|
24
|
+
return [] if compatibility["status"] == "compatible"
|
|
25
|
+
|
|
26
|
+
compatibility.fetch("differences", []).map do |difference|
|
|
27
|
+
"Environment changed: #{difference["field"]} (#{difference["severity"]})."
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def regressed?(metric_result)
|
|
32
|
+
metric_result && metric_result["decision"] != "pass" && (metric_result["absolute_change"] || 0).positive?
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def sql_count_rule(metrics)
|
|
36
|
+
metric = metrics["sql_count"]
|
|
37
|
+
return nil unless regressed?(metric)
|
|
38
|
+
|
|
39
|
+
"SQL query count increased by #{metric["absolute_change"].to_i}."
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def sql_duration_without_count_rule(metrics)
|
|
43
|
+
return nil unless regressed?(metrics["sql_duration"]) && !regressed?(metrics["sql_count"])
|
|
44
|
+
|
|
45
|
+
"SQL duration increased without a corresponding increase in query count."
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def allocations_rule(metrics)
|
|
49
|
+
metric = metrics["allocations"]
|
|
50
|
+
return nil unless regressed?(metric)
|
|
51
|
+
|
|
52
|
+
"Allocations increased by #{metric["change_percent"]}%."
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def duration_without_resource_change_rule(metrics)
|
|
56
|
+
return nil unless regressed?(metrics["duration"])
|
|
57
|
+
|
|
58
|
+
resource_metrics = metrics.values_at("sql_count", "sql_duration", "allocations").compact
|
|
59
|
+
return nil if resource_metrics.empty? || resource_metrics.any? { |metric| metric["decision"] != "pass" }
|
|
60
|
+
|
|
61
|
+
"Duration increased while SQL and allocation metrics remained stable."
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def noise_rules(metrics)
|
|
65
|
+
metrics.select { |_name, metric| metric["noisy"] }
|
|
66
|
+
.map { |name, _metric| "Sample variability is high for #{name}; treat this decision with caution." }
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "workload_comparison"
|
|
4
|
+
require_relative "diagnostics"
|
|
5
|
+
require_relative "../fingerprints/compatibility"
|
|
6
|
+
|
|
7
|
+
module Perfgate
|
|
8
|
+
module Comparison
|
|
9
|
+
# Builds the schema_version 1 comparison-result document (spec
|
|
10
|
+
# section 14.2) from a baseline run-result and a candidate
|
|
11
|
+
# run-result. This is the seam between the fingerprinting/statistics
|
|
12
|
+
# primitives and the CLI: it never touches storage or exit codes
|
|
13
|
+
# (that's Policy::Engine's job), only produces the structured
|
|
14
|
+
# decision document.
|
|
15
|
+
#
|
|
16
|
+
# Compatibility is checked first (spec section 15): when the runs are
|
|
17
|
+
# incompatible, no per-workload metric decisions are computed at all
|
|
18
|
+
# -- Baseline never silently compares runs it can't vouch for.
|
|
19
|
+
module Engine
|
|
20
|
+
SCHEMA_VERSION = 1
|
|
21
|
+
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
def compare(baseline_run:, candidate_run:, config: Perfgate.configuration)
|
|
25
|
+
compatibility = evaluate_compatibility(baseline_run, candidate_run, config)
|
|
26
|
+
workloads = workloads_for(compatibility, baseline_run, candidate_run, config)
|
|
27
|
+
|
|
28
|
+
build_document(baseline_run, candidate_run, compatibility, workloads)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def build_document(baseline_run, candidate_run, compatibility, workloads)
|
|
32
|
+
{
|
|
33
|
+
"schema_version" => SCHEMA_VERSION,
|
|
34
|
+
"baseline_run_id" => baseline_run["run_id"],
|
|
35
|
+
"candidate_run_id" => candidate_run["run_id"],
|
|
36
|
+
"compatibility" => compatibility,
|
|
37
|
+
"decision" => overall_decision(compatibility, workloads),
|
|
38
|
+
"workloads" => workloads,
|
|
39
|
+
"diagnostics" => diagnostics(baseline_run, candidate_run, compatibility)
|
|
40
|
+
}
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def workloads_for(compatibility, baseline_run, candidate_run, config)
|
|
44
|
+
return [] if compatibility["status"] == "incompatible"
|
|
45
|
+
|
|
46
|
+
WorkloadComparison.compare_all(baseline_run, candidate_run, config)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def evaluate_compatibility(baseline_run, candidate_run, config)
|
|
50
|
+
Fingerprints::Compatibility.evaluate(
|
|
51
|
+
baseline_components: baseline_run.fetch("fingerprint", {}),
|
|
52
|
+
candidate_components: candidate_run.fetch("fingerprint", {}), config: config
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def overall_decision(compatibility, workloads)
|
|
57
|
+
return "incompatible" if compatibility["status"] == "incompatible"
|
|
58
|
+
|
|
59
|
+
decisions = workloads.map { |w| w["decision"] }
|
|
60
|
+
return "fail" if decisions.include?("fail")
|
|
61
|
+
return "warn" if decisions.intersect?(%w[warn incomparable removed_workload new_workload])
|
|
62
|
+
|
|
63
|
+
"pass"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def diagnostics(baseline_run, candidate_run, compatibility)
|
|
67
|
+
diagnostics = []
|
|
68
|
+
diagnostics << { "code" => "empty_baseline", "message" => "baseline run has no workloads" } if
|
|
69
|
+
baseline_run.fetch("workloads", []).empty?
|
|
70
|
+
diagnostics << { "code" => "empty_candidate", "message" => "candidate run has no workloads" } if
|
|
71
|
+
candidate_run.fetch("workloads", []).empty?
|
|
72
|
+
Diagnostics.environment_changed_rules(compatibility).each do |message|
|
|
73
|
+
diagnostics << { "code" => "environment_changed", "message" => message }
|
|
74
|
+
end
|
|
75
|
+
diagnostics
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../statistics/summary"
|
|
4
|
+
|
|
5
|
+
module Perfgate
|
|
6
|
+
module Comparison
|
|
7
|
+
# Shared helpers for building a metric comparison result and for
|
|
8
|
+
# computing the baseline->candidate change that every decision path
|
|
9
|
+
# (statistical or deterministic) needs (spec section 14.2's
|
|
10
|
+
# `workloads[].metrics` entries).
|
|
11
|
+
module MetricChange
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def inconclusive(metric)
|
|
15
|
+
base_fields(metric).merge("decision" => "inconclusive")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def base_fields(metric)
|
|
19
|
+
{
|
|
20
|
+
"metric" => metric.to_s,
|
|
21
|
+
"baseline_median" => nil,
|
|
22
|
+
"candidate_median" => nil,
|
|
23
|
+
"change_percent" => nil,
|
|
24
|
+
"absolute_change" => nil,
|
|
25
|
+
"confidence" => nil,
|
|
26
|
+
"practically_significant" => false,
|
|
27
|
+
"noisy" => false
|
|
28
|
+
}
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def summarize(metric, baseline_samples, candidate_samples)
|
|
32
|
+
baseline_summary = Statistics::Summary.call(baseline_samples)
|
|
33
|
+
candidate_summary = Statistics::Summary.call(candidate_samples)
|
|
34
|
+
absolute_change = candidate_summary["median"] - baseline_summary["median"]
|
|
35
|
+
change_percent = percent_change(baseline_summary["median"], absolute_change)
|
|
36
|
+
|
|
37
|
+
build_change(metric, baseline_summary, candidate_summary, absolute_change, change_percent)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def build_change(metric, baseline_summary, candidate_summary, absolute_change, change_percent)
|
|
41
|
+
{
|
|
42
|
+
metric: metric.to_s,
|
|
43
|
+
baseline_summary: baseline_summary,
|
|
44
|
+
candidate_summary: candidate_summary,
|
|
45
|
+
absolute_change: absolute_change,
|
|
46
|
+
change_percent: change_percent
|
|
47
|
+
}
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def percent_change(baseline_median, absolute_change)
|
|
51
|
+
return 0.0 if baseline_median.zero?
|
|
52
|
+
|
|
53
|
+
((absolute_change / baseline_median.to_f) * 100).round(2)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def result(change, confidence:, practically_significant:, noisy:, decision:)
|
|
57
|
+
change_fields(change).merge(
|
|
58
|
+
"confidence" => confidence, "practically_significant" => practically_significant,
|
|
59
|
+
"noisy" => noisy, "decision" => decision
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def change_fields(change)
|
|
64
|
+
{
|
|
65
|
+
"metric" => change[:metric],
|
|
66
|
+
"baseline_median" => change[:baseline_summary]["median"],
|
|
67
|
+
"candidate_median" => change[:candidate_summary]["median"],
|
|
68
|
+
"change_percent" => change[:change_percent],
|
|
69
|
+
"absolute_change" => change[:absolute_change]
|
|
70
|
+
}
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# A metric is "noisy" when its own baseline spread (MAD relative to
|
|
74
|
+
# its median) is large enough that a modest shift in medians could
|
|
75
|
+
# plausibly be explained by run-to-run variance alone (spec 16.5).
|
|
76
|
+
def noisy?(baseline_summary, config)
|
|
77
|
+
median = baseline_summary["median"]
|
|
78
|
+
return false if median.zero?
|
|
79
|
+
|
|
80
|
+
(baseline_summary["mad"] / median.to_f) > config.comparison_noise_ratio_threshold
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "metric_change"
|
|
4
|
+
require_relative "statistical_metric_decision"
|
|
5
|
+
require_relative "deterministic_metric_decision"
|
|
6
|
+
|
|
7
|
+
module Perfgate
|
|
8
|
+
module Comparison
|
|
9
|
+
# Decides pass/warn/fail/inconclusive for a single metric on a single
|
|
10
|
+
# workload (spec section 16). A regression is only ever reported as
|
|
11
|
+
# "fail" when it clears both bars: a practically-significant change
|
|
12
|
+
# (spec 16.4's thresholds) and statistical support that it isn't just
|
|
13
|
+
# sampling noise (spec 16.3's Mann-Whitney test) -- see
|
|
14
|
+
# spikes/FINDINGS.md for why Milestone 0 settled on requiring both.
|
|
15
|
+
# `sql_count` is the one exception: it's compared deterministically
|
|
16
|
+
# (spec 16.3), since query counts are rarely continuous/noisy the way
|
|
17
|
+
# timing and allocation metrics are.
|
|
18
|
+
module MetricDecision
|
|
19
|
+
DETERMINISTIC_METRICS = %w[sql_count].freeze
|
|
20
|
+
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
def call(metric:, baseline_samples:, candidate_samples:, config: Perfgate.configuration)
|
|
24
|
+
return MetricChange.inconclusive(metric) unless enough_samples?(baseline_samples, candidate_samples, config)
|
|
25
|
+
|
|
26
|
+
if DETERMINISTIC_METRICS.include?(metric.to_s)
|
|
27
|
+
DeterministicMetricDecision.call(metric, baseline_samples, candidate_samples, config)
|
|
28
|
+
else
|
|
29
|
+
StatisticalMetricDecision.call(metric, baseline_samples, candidate_samples, config)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def enough_samples?(baseline_samples, candidate_samples, config)
|
|
34
|
+
minimum = config.comparison_minimum_samples
|
|
35
|
+
baseline_samples.size >= minimum && candidate_samples.size >= minimum
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|