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
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Perfgate
|
|
4
|
+
module Report
|
|
5
|
+
# Renders the schema_version 1 comparison-result document (plus its
|
|
6
|
+
# Policy::Engine verdict) as a Markdown report, per spec 20.2: the
|
|
7
|
+
# overall decision, run identities, compatibility status, a
|
|
8
|
+
# per-workload/per-metric table, noise warnings, diagnostics, a
|
|
9
|
+
# pointer to the machine-readable JSON, and an explanation of the
|
|
10
|
+
# exit code. This is what `baseline run --format markdown` writes
|
|
11
|
+
# and what the GitHub Actions example appends to the job summary.
|
|
12
|
+
module Markdown
|
|
13
|
+
DURATION_METRICS = %w[duration sql_duration].freeze
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def render(comparison_result:, policy_result:, comparison_path: nil)
|
|
18
|
+
[
|
|
19
|
+
header(policy_result),
|
|
20
|
+
identities(comparison_result),
|
|
21
|
+
"**Compatibility:** #{comparison_result.dig("compatibility", "status")}",
|
|
22
|
+
workloads_table(comparison_result),
|
|
23
|
+
diagnostics_section(comparison_result),
|
|
24
|
+
machine_readable_section(comparison_path),
|
|
25
|
+
exit_code_section(policy_result)
|
|
26
|
+
].compact.join("\n\n")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def header(policy_result)
|
|
30
|
+
"## Baseline Performance Assurance\n\n**Overall:** #{policy_result["status"].upcase}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def identities(comparison_result)
|
|
34
|
+
"- Baseline run: `#{comparison_result["baseline_run_id"]}`\n" \
|
|
35
|
+
"- Candidate run: `#{comparison_result["candidate_run_id"]}`"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def workloads_table(comparison_result)
|
|
39
|
+
workloads = comparison_result.fetch("workloads", [])
|
|
40
|
+
return "_No workloads were compared._" if workloads.empty?
|
|
41
|
+
|
|
42
|
+
([table_header] + workloads.flat_map { |workload| workload_rows(workload) }).join("\n")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def table_header
|
|
46
|
+
"| Workload | Metric | Baseline | Candidate | Change | Decision |\n|---|---|---|---|---|---|"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def workload_rows(workload)
|
|
50
|
+
return [summary_row(workload)] if workload["metrics"].empty?
|
|
51
|
+
|
|
52
|
+
workload["metrics"].map { |name, metric| metric_row(workload["id"], name, metric) }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def summary_row(workload)
|
|
56
|
+
"| #{workload["id"]} | - | - | - | - | #{workload["decision"].upcase} |"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def metric_row(workload_id, name, metric)
|
|
60
|
+
noise = metric["noisy"] ? " ⚠️ noisy" : ""
|
|
61
|
+
"| #{workload_id} | #{name} | #{format_value(name, metric["baseline_median"])} | " \
|
|
62
|
+
"#{format_value(name, metric["candidate_median"])} | #{format_change(metric["change_percent"])} | " \
|
|
63
|
+
"#{metric["decision"].upcase}#{noise} |"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def format_change(percent)
|
|
67
|
+
percent ? format("%+.1f%%", percent) : "n/a"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def format_value(name, value)
|
|
71
|
+
return "n/a" if value.nil?
|
|
72
|
+
|
|
73
|
+
DURATION_METRICS.include?(name) ? format("%.2fms", value / 1_000_000.0) : value.to_s
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def diagnostics_section(comparison_result)
|
|
77
|
+
messages = diagnostic_messages(comparison_result)
|
|
78
|
+
return nil if messages.empty?
|
|
79
|
+
|
|
80
|
+
"**Diagnostics:**\n#{messages.map { |message| "- #{message}" }.join("\n")}"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def diagnostic_messages(comparison_result)
|
|
84
|
+
workload_messages = comparison_result.fetch("workloads", []).flat_map { |w| w["diagnostics"] || [] }
|
|
85
|
+
run_messages = comparison_result.fetch("diagnostics", []).map { |d| d["message"] }
|
|
86
|
+
workload_messages + run_messages
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def machine_readable_section(comparison_path)
|
|
90
|
+
return nil unless comparison_path
|
|
91
|
+
|
|
92
|
+
"Machine-readable result: `#{comparison_path}`"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def exit_code_section(policy_result)
|
|
96
|
+
"Exit code `#{policy_result["exit_code"]}` (#{policy_result["status"]}). " \
|
|
97
|
+
"See the exit-code table in the docs for what each status means for CI."
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
File without changes
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "workload_builder"
|
|
4
|
+
|
|
5
|
+
module Perfgate
|
|
6
|
+
module RSpec
|
|
7
|
+
# Discovers RSpec examples tagged for Baseline (`:baseline` metadata,
|
|
8
|
+
# spec section 9.1) after spec files have been loaded, and registers
|
|
9
|
+
# a Workload for each one.
|
|
10
|
+
#
|
|
11
|
+
# `::RSpec.world.all_examples` is a private RSpec::Core API, but it is
|
|
12
|
+
# widely relied on by tooling that inspects a loaded suite and has
|
|
13
|
+
# been stable across RSpec 3.x releases.
|
|
14
|
+
module Discovery
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def call(registry: Perfgate.registry, builder: default_builder)
|
|
18
|
+
::RSpec.world.all_examples.each do |example|
|
|
19
|
+
next unless example.metadata[:perfgate]
|
|
20
|
+
|
|
21
|
+
registry.register(builder.build(example))
|
|
22
|
+
end
|
|
23
|
+
registry
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def default_builder
|
|
27
|
+
WorkloadBuilder.new(defaults: Perfgate.configuration.execution_defaults)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
|
|
5
|
+
module Perfgate
|
|
6
|
+
module RSpec
|
|
7
|
+
# Computes the stable workload identifier for an RSpec example, per
|
|
8
|
+
# spec section 9.2: "<spec file relative path>:<RSpec example full
|
|
9
|
+
# description>", unless overridden via `perfgate: { id: "..." }`.
|
|
10
|
+
module IdResolver
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def resolve(example)
|
|
14
|
+
explicit_id(example) || "#{relative_spec_path(example)}:#{example.full_description}"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def explicit_id(example)
|
|
18
|
+
metadata = example.metadata[:perfgate]
|
|
19
|
+
return nil unless metadata.is_a?(Hash)
|
|
20
|
+
|
|
21
|
+
metadata[:id]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def relative_spec_path(example)
|
|
25
|
+
absolute = File.expand_path(example.metadata[:file_path])
|
|
26
|
+
Pathname.new(absolute).relative_path_from(Pathname.pwd).to_s
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../workloads/workload"
|
|
4
|
+
require_relative "id_resolver"
|
|
5
|
+
|
|
6
|
+
module Perfgate
|
|
7
|
+
module RSpec
|
|
8
|
+
# Builds a Workloads::Workload that wraps a single RSpec example.
|
|
9
|
+
#
|
|
10
|
+
# Each call re-instantiates the example group and re-runs the example
|
|
11
|
+
# via RSpec's own `Example#run(instance, reporter)`, which is safe to
|
|
12
|
+
# invoke repeatedly: it resets the example's execution result and
|
|
13
|
+
# exercises the full before/around/after hook chain each time (spec
|
|
14
|
+
# section 12.2, "reset workload state" + "execute measured samples").
|
|
15
|
+
# A ::RSpec::Core::NullReporter discards RSpec's own reporting, since
|
|
16
|
+
# Baseline does its own result collection.
|
|
17
|
+
class WorkloadBuilder
|
|
18
|
+
def initialize(defaults:)
|
|
19
|
+
@defaults = defaults
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def build(example)
|
|
23
|
+
options = example.metadata[:perfgate]
|
|
24
|
+
options = {} unless options.is_a?(Hash)
|
|
25
|
+
|
|
26
|
+
Workloads::Workload.new(
|
|
27
|
+
id: IdResolver.resolve(example),
|
|
28
|
+
samples: options.fetch(:samples, @defaults.fetch(:samples)),
|
|
29
|
+
warmup: options.fetch(:warmup, @defaults.fetch(:warmup)),
|
|
30
|
+
metrics: options.fetch(:metrics, @defaults.fetch(:metrics))
|
|
31
|
+
) { run_example(example) }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def run_example(example)
|
|
37
|
+
instance = example.example_group.new
|
|
38
|
+
example.run(instance, ::RSpec::Core::NullReporter)
|
|
39
|
+
|
|
40
|
+
exception = example.execution_result.exception
|
|
41
|
+
return unless exception
|
|
42
|
+
|
|
43
|
+
raise Perfgate::WorkloadError,
|
|
44
|
+
"workload #{example.full_description.inspect} failed: #{exception.message}"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rspec/core"
|
|
4
|
+
require_relative "rspec/id_resolver"
|
|
5
|
+
require_relative "rspec/workload_builder"
|
|
6
|
+
require_relative "rspec/discovery"
|
|
7
|
+
|
|
8
|
+
module Perfgate
|
|
9
|
+
# RSpec integration entry point. Require "baselined/rspec" (typically
|
|
10
|
+
# from spec_helper.rb) to enable `:baseline`-tagged examples and
|
|
11
|
+
# `Perfgate.measure`. See spec section 9 for the public API contract.
|
|
12
|
+
module RSpec
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "time"
|
|
5
|
+
require_relative "../statistics/summary"
|
|
6
|
+
require_relative "../fingerprints/components"
|
|
7
|
+
|
|
8
|
+
module Perfgate
|
|
9
|
+
module Serialization
|
|
10
|
+
# Builds the schema_version 1 run-result document described in spec
|
|
11
|
+
# section 14.1. Milestone 5 will add source metadata; fingerprinting
|
|
12
|
+
# (spec section 15) is populated here so the comparison engine can
|
|
13
|
+
# decide compatibility without re-deriving it from scratch.
|
|
14
|
+
module RunResult
|
|
15
|
+
SCHEMA_VERSION = 1
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
def build(workload_results, config: Perfgate.configuration)
|
|
20
|
+
{
|
|
21
|
+
"schema_version" => SCHEMA_VERSION,
|
|
22
|
+
"run_id" => SecureRandom.uuid,
|
|
23
|
+
"created_at" => Time.now.utc.iso8601,
|
|
24
|
+
"fingerprint" => Fingerprints::Components.collect(config: config),
|
|
25
|
+
"workloads" => workload_results.map { |result| build_workload(result) }
|
|
26
|
+
}
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def build_workload(result)
|
|
30
|
+
samples = result.fetch("samples")
|
|
31
|
+
|
|
32
|
+
{
|
|
33
|
+
"id" => result.fetch("id"),
|
|
34
|
+
"status" => result.fetch("status"),
|
|
35
|
+
"error" => result["error"],
|
|
36
|
+
"definition_hash" => result["definition_hash"],
|
|
37
|
+
"samples" => samples,
|
|
38
|
+
"summary" => summarize(samples)
|
|
39
|
+
}
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Every metric key present in at least one sample gets its own
|
|
43
|
+
# summary block (spec section 14.1 shows this for duration_ns, but
|
|
44
|
+
# the same shape applies to sql_count, sql_duration_ns, etc. once
|
|
45
|
+
# those metrics are enabled).
|
|
46
|
+
def summarize(samples)
|
|
47
|
+
metric_keys(samples).each_with_object({}) do |key, summary|
|
|
48
|
+
values = samples.map { |sample| sample[key] }.compact
|
|
49
|
+
summary[key] = Statistics::Summary.call(values)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def metric_keys(samples)
|
|
54
|
+
samples.each_with_object([]) { |sample, keys| keys.concat(sample.keys) }.uniq
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Perfgate
|
|
4
|
+
module Statistics
|
|
5
|
+
# One-sided Mann-Whitney U test: estimates how likely it is that the
|
|
6
|
+
# candidate distribution is drawn from a "worse" (larger-valued)
|
|
7
|
+
# population than the baseline distribution, without assuming
|
|
8
|
+
# normality (spec section 16.3). Uses a normal approximation with a
|
|
9
|
+
# tie correction, adapted from the Milestone 0 spike
|
|
10
|
+
# (spikes/regression_injection.rb) that validated this approach
|
|
11
|
+
# against a seeded +20% duration regression.
|
|
12
|
+
#
|
|
13
|
+
# Baseline never surfaces this p-value directly to users (spec
|
|
14
|
+
# 16.3: "do not expose p-values alone as user-facing proof") -- it is
|
|
15
|
+
# combined with a practical-significance threshold by
|
|
16
|
+
# Comparison::MetricDecision.
|
|
17
|
+
module MannWhitneyU
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
# Returns p, the probability of observing rank sums this extreme
|
|
21
|
+
# (or more) under the null hypothesis of no difference, tested
|
|
22
|
+
# against the one-sided alternative that `candidate` tends to be
|
|
23
|
+
# larger than `baseline`. Smaller p is stronger evidence the
|
|
24
|
+
# candidate is worse.
|
|
25
|
+
def one_sided_p(baseline, candidate)
|
|
26
|
+
return 1.0 if baseline.empty? || candidate.empty?
|
|
27
|
+
|
|
28
|
+
z = z_score(baseline, candidate)
|
|
29
|
+
return 0.5 if z.nil?
|
|
30
|
+
|
|
31
|
+
0.5 * Math.erfc(z / Math.sqrt(2))
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Standardized U statistic for the candidate sample, or nil when the
|
|
35
|
+
# null distribution has zero variance (e.g. every sample tied).
|
|
36
|
+
def z_score(baseline, candidate)
|
|
37
|
+
n1 = baseline.size
|
|
38
|
+
n2 = candidate.size
|
|
39
|
+
u_candidate = candidate_rank_sum(baseline, candidate) - (n2 * (n2 + 1) / 2.0)
|
|
40
|
+
std_u = standard_deviation_u(n1, n2)
|
|
41
|
+
return nil if std_u.zero?
|
|
42
|
+
|
|
43
|
+
(u_candidate - (n1 * n2 / 2.0)) / std_u
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def standard_deviation_u(baseline_size, candidate_size)
|
|
47
|
+
Math.sqrt(baseline_size * candidate_size * (baseline_size + candidate_size + 1) / 12.0)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def candidate_rank_sum(baseline, candidate)
|
|
51
|
+
ranks = rank(baseline.map { |v| [v, :baseline] } + candidate.map { |v| [v, :candidate] })
|
|
52
|
+
ranks.each_index.sum { |i| ranks[i][1] == :candidate ? ranks[i][2] : 0 }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Assigns tied (averaged) ranks to a list of [value, label] pairs,
|
|
56
|
+
# returning [value, label, rank] triples sorted by value.
|
|
57
|
+
def rank(pairs)
|
|
58
|
+
sorted = pairs.sort_by { |value, _label| value }
|
|
59
|
+
ranked = Array.new(sorted.size)
|
|
60
|
+
|
|
61
|
+
index = 0
|
|
62
|
+
index = assign_tie_group(sorted, ranked, index) while index < sorted.size
|
|
63
|
+
|
|
64
|
+
ranked
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Finds the run of tied values starting at start_index, assigns
|
|
68
|
+
# them all the same averaged rank, and returns the index just past
|
|
69
|
+
# the run.
|
|
70
|
+
def assign_tie_group(sorted, ranked, start_index)
|
|
71
|
+
end_index = tie_group_end(sorted, start_index)
|
|
72
|
+
average_rank = ((start_index + 1) + (end_index + 1)) / 2.0
|
|
73
|
+
(start_index..end_index).each { |k| ranked[k] = sorted[k] + [average_rank] }
|
|
74
|
+
end_index + 1
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def tie_group_end(sorted, start_index)
|
|
78
|
+
end_index = start_index
|
|
79
|
+
end_index += 1 while end_index + 1 < sorted.size && sorted[end_index + 1][0] == sorted[start_index][0]
|
|
80
|
+
end_index
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Perfgate
|
|
4
|
+
module Statistics
|
|
5
|
+
# Basic summary statistics for a set of raw sample values, used to
|
|
6
|
+
# populate the `summary` block of a run result (spec sections 14.1
|
|
7
|
+
# and 16.2).
|
|
8
|
+
module Summary
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def call(values)
|
|
12
|
+
return empty if values.empty?
|
|
13
|
+
|
|
14
|
+
sorted = values.sort
|
|
15
|
+
med = median(sorted)
|
|
16
|
+
|
|
17
|
+
central_tendency(values, sorted, med).merge("count" => values.size)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def central_tendency(values, sorted, med)
|
|
21
|
+
{
|
|
22
|
+
"mean" => mean(values).round,
|
|
23
|
+
"median" => med.round,
|
|
24
|
+
"min" => sorted.first.round,
|
|
25
|
+
"max" => sorted.last.round,
|
|
26
|
+
"mad" => mad(sorted, med).round,
|
|
27
|
+
"p90" => percentile(sorted, 90).round
|
|
28
|
+
}
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def mean(values)
|
|
32
|
+
values.sum / values.size.to_f
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def median(sorted)
|
|
36
|
+
percentile(sorted, 50)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Median absolute deviation: a robust spread measure, less sensitive
|
|
40
|
+
# to outliers than standard deviation.
|
|
41
|
+
def mad(sorted, med)
|
|
42
|
+
deviations = sorted.map { |v| (v - med).abs }.sort
|
|
43
|
+
percentile(deviations, 50)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def percentile(sorted, pct)
|
|
47
|
+
return sorted.first.to_f if sorted.size == 1
|
|
48
|
+
|
|
49
|
+
rank = (pct / 100.0) * (sorted.size - 1)
|
|
50
|
+
lower = sorted[rank.floor]
|
|
51
|
+
upper = sorted[rank.ceil]
|
|
52
|
+
lower + ((upper - lower) * (rank - rank.floor))
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def empty
|
|
56
|
+
{ "mean" => 0, "median" => 0, "min" => 0, "max" => 0, "mad" => 0, "p90" => 0, "count" => 0 }
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Perfgate
|
|
4
|
+
module Storage
|
|
5
|
+
# Interface every storage backend implements (spec section 18.1).
|
|
6
|
+
class Adapter
|
|
7
|
+
def save_run(run_result)
|
|
8
|
+
raise NotImplementedError, "#{self.class} must implement #save_run"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def load_run(reference)
|
|
12
|
+
raise NotImplementedError, "#{self.class} must implement #load_run"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def list_runs(filters = {})
|
|
16
|
+
raise NotImplementedError, "#{self.class} must implement #list_runs"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def save_comparison(comparison_result)
|
|
20
|
+
raise NotImplementedError, "#{self.class} must implement #save_comparison"
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "rubygems/package"
|
|
5
|
+
require "stringio"
|
|
6
|
+
require "zlib"
|
|
7
|
+
|
|
8
|
+
module Perfgate
|
|
9
|
+
module Storage
|
|
10
|
+
# Packs/unpacks the portable baseline-run-<run-id>.tar.gz archive
|
|
11
|
+
# format (spec 18.2), used to move a run bundle outside of a
|
|
12
|
+
# Filesystem adapter's own root -- e.g. as a manually-downloaded CI
|
|
13
|
+
# artifact. Extraction rejects any entry that would escape the
|
|
14
|
+
# destination directory (spec 22: reject path traversal).
|
|
15
|
+
module Archive
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
def write(archive_path, dir, run_id)
|
|
19
|
+
tar_io = StringIO.new
|
|
20
|
+
Gem::Package::TarWriter.new(tar_io) do |tar|
|
|
21
|
+
Dir.glob("**/*", File::FNM_DOTMATCH, base: dir).each { |entry| add_entry(tar, dir, run_id, entry) }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
Zlib::GzipWriter.open(archive_path) { |gz| gz.write(tar_io.string) }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def extract(archive_path, into)
|
|
28
|
+
Zlib::GzipReader.open(archive_path) do |gz|
|
|
29
|
+
Gem::Package::TarReader.new(gz) { |tar| extract_entries(tar, into) }
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def add_entry(tar, dir, run_id, entry)
|
|
34
|
+
return if [".", ".."].include?(File.basename(entry))
|
|
35
|
+
|
|
36
|
+
source = File.join(dir, entry)
|
|
37
|
+
name = File.join(run_id, entry)
|
|
38
|
+
if File.directory?(source)
|
|
39
|
+
tar.mkdir(name, 0o755)
|
|
40
|
+
else
|
|
41
|
+
tar.add_file(name, 0o644) { |io| io.write(File.read(source)) }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def extract_entries(tar, into)
|
|
46
|
+
tar.each do |entry|
|
|
47
|
+
destination = safe_destination(into, entry.full_name)
|
|
48
|
+
entry.directory? ? FileUtils.mkdir_p(destination) : extract_file(entry, destination)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def extract_file(entry, destination)
|
|
53
|
+
FileUtils.mkdir_p(File.dirname(destination))
|
|
54
|
+
File.write(destination, entry.read)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def safe_destination(into, entry_name)
|
|
58
|
+
base = File.join(into, "runs")
|
|
59
|
+
destination = File.expand_path(File.join(base, entry_name))
|
|
60
|
+
|
|
61
|
+
unless destination.start_with?("#{File.expand_path(base)}/")
|
|
62
|
+
raise Perfgate::ResultBundleError, "archive entry #{entry_name.inspect} escapes the destination directory"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
destination
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "json"
|
|
6
|
+
require "securerandom"
|
|
7
|
+
require_relative "adapter"
|
|
8
|
+
require_relative "archive"
|
|
9
|
+
|
|
10
|
+
module Perfgate
|
|
11
|
+
module Storage
|
|
12
|
+
# Default MVP storage adapter (spec section 18.2). Layout:
|
|
13
|
+
#
|
|
14
|
+
# <root>/runs/<run-id>/manifest.json
|
|
15
|
+
# <root>/runs/<run-id>/run.json
|
|
16
|
+
# <root>/runs/<run-id>/checksums.json
|
|
17
|
+
# <root>/comparisons/<comparison-id>.json
|
|
18
|
+
class Filesystem < Adapter
|
|
19
|
+
def initialize(root:)
|
|
20
|
+
super()
|
|
21
|
+
@root = root
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def save_run(run_result)
|
|
25
|
+
run_id = run_result.fetch("run_id")
|
|
26
|
+
dir = run_directory(run_id)
|
|
27
|
+
FileUtils.mkdir_p(dir)
|
|
28
|
+
|
|
29
|
+
run_json = JSON.pretty_generate(run_result)
|
|
30
|
+
File.write(File.join(dir, "run.json"), run_json)
|
|
31
|
+
File.write(File.join(dir, "manifest.json"), JSON.pretty_generate(manifest_for(run_result)))
|
|
32
|
+
File.write(File.join(dir, "checksums.json"), JSON.pretty_generate(checksums_for(run_json)))
|
|
33
|
+
|
|
34
|
+
dir
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def load_run(reference)
|
|
38
|
+
dir = File.directory?(reference) ? reference : run_directory(reference)
|
|
39
|
+
run_json_path = File.join(dir, "run.json")
|
|
40
|
+
|
|
41
|
+
raise Perfgate::ResultBundleError, "no run result found at #{run_json_path}" unless File.exist?(run_json_path)
|
|
42
|
+
|
|
43
|
+
verify_checksum!(dir, run_json_path)
|
|
44
|
+
JSON.parse(File.read(run_json_path))
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def list_runs(filters = {}) # rubocop:disable Lint/UnusedMethodArgument
|
|
48
|
+
base = File.join(@root, "runs")
|
|
49
|
+
return [] unless File.directory?(base)
|
|
50
|
+
|
|
51
|
+
Dir.children(base).sort
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def save_comparison(comparison_result)
|
|
55
|
+
dir = File.join(@root, "comparisons")
|
|
56
|
+
FileUtils.mkdir_p(dir)
|
|
57
|
+
|
|
58
|
+
comparison_id = SecureRandom.uuid
|
|
59
|
+
path = File.join(dir, "#{comparison_id}.json")
|
|
60
|
+
File.write(path, JSON.pretty_generate(comparison_result))
|
|
61
|
+
|
|
62
|
+
path
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Packs a run bundle's directory into the portable
|
|
66
|
+
# baseline-run-<run-id>.tar.gz format (spec 18.2), for manual
|
|
67
|
+
# transfer or storage outside the filesystem adapter's own root
|
|
68
|
+
# (e.g. as a CI artifact). Returns the archive's path.
|
|
69
|
+
def export_archive(run_id, into: @root)
|
|
70
|
+
dir = run_directory(run_id)
|
|
71
|
+
raise Perfgate::ResultBundleError, "no run bundle found at #{dir}" unless File.directory?(dir)
|
|
72
|
+
|
|
73
|
+
FileUtils.mkdir_p(into)
|
|
74
|
+
archive_path = File.join(into, "baseline-run-#{run_id}.tar.gz")
|
|
75
|
+
Archive.write(archive_path, dir, run_id)
|
|
76
|
+
archive_path
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Unpacks a baseline-run-<run-id>.tar.gz archive into `into`,
|
|
80
|
+
# rejecting any entry that would escape the destination directory
|
|
81
|
+
# (spec 22: reject path traversal in archives). Returns the
|
|
82
|
+
# extracted run directory path.
|
|
83
|
+
def import_archive(archive_path, into: @root)
|
|
84
|
+
Archive.extract(archive_path, into)
|
|
85
|
+
File.join(into, "runs", File.basename(archive_path, ".tar.gz").sub(/^baseline-run-/, ""))
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def run_directory(run_id)
|
|
91
|
+
File.join(@root, "runs", run_id)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def manifest_for(run_result)
|
|
95
|
+
{
|
|
96
|
+
"schema_version" => run_result["schema_version"],
|
|
97
|
+
"run_id" => run_result["run_id"],
|
|
98
|
+
"created_at" => run_result["created_at"],
|
|
99
|
+
"workload_ids" => run_result.fetch("workloads").map { |w| w["id"] }
|
|
100
|
+
}
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def checksums_for(run_json)
|
|
104
|
+
{ "run.json" => "sha256:#{Digest::SHA256.hexdigest(run_json)}" }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def verify_checksum!(dir, run_json_path)
|
|
108
|
+
checksums_path = File.join(dir, "checksums.json")
|
|
109
|
+
return unless File.exist?(checksums_path)
|
|
110
|
+
|
|
111
|
+
expected = JSON.parse(File.read(checksums_path))["run.json"]
|
|
112
|
+
return unless expected
|
|
113
|
+
|
|
114
|
+
actual = "sha256:#{Digest::SHA256.hexdigest(File.read(run_json_path))}"
|
|
115
|
+
return if actual == expected
|
|
116
|
+
|
|
117
|
+
raise Perfgate::ResultBundleError, "checksum mismatch for #{run_json_path}"
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
File without changes
|