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,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "metric_change"
|
|
4
|
+
require_relative "../statistics/mann_whitney_u"
|
|
5
|
+
|
|
6
|
+
module Perfgate
|
|
7
|
+
module Comparison
|
|
8
|
+
# Statistical decision path used for continuous, noisy metrics
|
|
9
|
+
# (duration, sql_duration, allocations): combines a practical
|
|
10
|
+
# threshold check with a one-sided Mann-Whitney U test, and
|
|
11
|
+
# downgrades a would-be fail to a warn when the metric is noisy
|
|
12
|
+
# (spec sections 16.3-16.5).
|
|
13
|
+
module StatisticalMetricDecision
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def call(metric, baseline_samples, candidate_samples, config)
|
|
17
|
+
change = MetricChange.summarize(metric, baseline_samples, candidate_samples)
|
|
18
|
+
thresholds = threshold_for(metric, config)
|
|
19
|
+
p_value = Statistics::MannWhitneyU.one_sided_p(baseline_samples, candidate_samples)
|
|
20
|
+
|
|
21
|
+
significance = significance_flags(change, thresholds, p_value, config)
|
|
22
|
+
noisy = MetricChange.noisy?(change[:baseline_summary], config)
|
|
23
|
+
decision = decide(significance[:exceeds_failure], significance[:statistically_significant],
|
|
24
|
+
significance[:practically_significant], noisy)
|
|
25
|
+
|
|
26
|
+
MetricChange.result(change, confidence: (1 - p_value).round(4),
|
|
27
|
+
practically_significant: significance[:practically_significant], noisy: noisy,
|
|
28
|
+
decision: decision)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def significance_flags(change, thresholds, p_value, config)
|
|
32
|
+
alpha = 1 - config.comparison_confidence_level
|
|
33
|
+
clears_floor = change[:absolute_change].abs >= thresholds[:minimum_absolute]
|
|
34
|
+
{
|
|
35
|
+
practically_significant: breaches?(change, thresholds[:warning_percent], thresholds[:minimum_absolute]),
|
|
36
|
+
exceeds_failure: breaches?(change, thresholds[:failure_percent], thresholds[:minimum_absolute]),
|
|
37
|
+
statistically_significant: clears_floor && change[:absolute_change].positive? && p_value < alpha
|
|
38
|
+
}
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def decide(exceeds_failure, statistically_significant, practically_significant, noisy)
|
|
42
|
+
if exceeds_failure && statistically_significant
|
|
43
|
+
noisy ? "warn" : "fail"
|
|
44
|
+
elsif practically_significant || statistically_significant
|
|
45
|
+
"warn"
|
|
46
|
+
else
|
|
47
|
+
"pass"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def threshold_for(metric, config)
|
|
52
|
+
raw = config.dig(:comparison, :practical_thresholds, metric.to_sym) || {}
|
|
53
|
+
{
|
|
54
|
+
warning_percent: raw[:warning_percent] || Float::INFINITY,
|
|
55
|
+
failure_percent: raw[:failure_percent] || Float::INFINITY,
|
|
56
|
+
# minimum_absolute_ms is configured in milliseconds, but samples
|
|
57
|
+
# (and therefore change[:absolute_change]) are always in the
|
|
58
|
+
# metric's raw unit, nanoseconds for duration/sql_duration - so
|
|
59
|
+
# it must be converted before comparison.
|
|
60
|
+
minimum_absolute: minimum_absolute_ns(raw)
|
|
61
|
+
}
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def minimum_absolute_ns(raw)
|
|
65
|
+
return raw[:minimum_absolute_ms] * 1_000_000 if raw[:minimum_absolute_ms]
|
|
66
|
+
|
|
67
|
+
raw[:minimum_absolute] || 0
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def breaches?(change, percent_threshold, minimum_absolute)
|
|
71
|
+
change[:change_percent].abs >= percent_threshold && change[:absolute_change].abs >= minimum_absolute
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "metric_decision"
|
|
4
|
+
require_relative "diagnostics"
|
|
5
|
+
|
|
6
|
+
module Perfgate
|
|
7
|
+
module Comparison
|
|
8
|
+
# Matches baseline/candidate workloads by id and produces each
|
|
9
|
+
# workload's decision entry in the comparison-result document (spec
|
|
10
|
+
# 14.2), including the deterministic per-workload diagnostics (spec
|
|
11
|
+
# 20.3). Split out of Engine to keep both modules under RuboCop's
|
|
12
|
+
# module-length limit.
|
|
13
|
+
module WorkloadComparison
|
|
14
|
+
# Maps the raw sample keys instrumentation writes (spec section 13)
|
|
15
|
+
# to the metric names practical_thresholds/fingerprint config uses
|
|
16
|
+
# (spec section 16.4). gc_* keys are deliberately left unmapped:
|
|
17
|
+
# GC activity is diagnostic-only and never drives a decision.
|
|
18
|
+
SAMPLE_KEY_TO_METRIC = {
|
|
19
|
+
"duration_ns" => "duration",
|
|
20
|
+
"sql_count" => "sql_count",
|
|
21
|
+
"sql_duration_ns" => "sql_duration",
|
|
22
|
+
"allocations" => "allocations"
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
def compare_all(baseline_run, candidate_run, config)
|
|
28
|
+
baseline_by_id = index_by_id(baseline_run)
|
|
29
|
+
candidate_by_id = index_by_id(candidate_run)
|
|
30
|
+
|
|
31
|
+
(baseline_by_id.keys | candidate_by_id.keys).map do |id|
|
|
32
|
+
compare_one(id, baseline_by_id[id], candidate_by_id[id], config)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def index_by_id(run)
|
|
37
|
+
run.fetch("workloads", []).to_h { |workload| [workload["id"], workload] }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def compare_one(id, baseline_workload, candidate_workload, config)
|
|
41
|
+
return missing_workload_result(id, "new_workload") unless baseline_workload
|
|
42
|
+
return missing_workload_result(id, "removed_workload") unless candidate_workload
|
|
43
|
+
|
|
44
|
+
definition_changed = baseline_workload["definition_hash"] != candidate_workload["definition_hash"]
|
|
45
|
+
return incomparable_workload_result(id) if definition_changed
|
|
46
|
+
|
|
47
|
+
metrics = metric_decisions(baseline_workload, candidate_workload, config)
|
|
48
|
+
{ "id" => id, "decision" => workload_decision(metrics), "metrics" => metrics,
|
|
49
|
+
"diagnostics" => Diagnostics.for_workload(metrics) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def missing_workload_result(id, reason)
|
|
53
|
+
message = if reason == "new_workload"
|
|
54
|
+
"new workload with no prior baseline run"
|
|
55
|
+
else
|
|
56
|
+
"workload removed since the baseline run"
|
|
57
|
+
end
|
|
58
|
+
{ "id" => id, "decision" => reason, "metrics" => {}, "diagnostics" => [message] }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def incomparable_workload_result(id)
|
|
62
|
+
{ "id" => id, "decision" => "incomparable", "metrics" => {},
|
|
63
|
+
"diagnostics" => ["Workload definition changed since the baseline run; comparison skipped."] }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def metric_decisions(baseline_workload, candidate_workload, config)
|
|
67
|
+
sample_keys(baseline_workload, candidate_workload).each_with_object({}) do |sample_key, acc|
|
|
68
|
+
metric = SAMPLE_KEY_TO_METRIC[sample_key]
|
|
69
|
+
next unless metric
|
|
70
|
+
|
|
71
|
+
baseline_samples = samples_for(baseline_workload, sample_key)
|
|
72
|
+
candidate_samples = samples_for(candidate_workload, sample_key)
|
|
73
|
+
next if baseline_samples.empty? || candidate_samples.empty?
|
|
74
|
+
|
|
75
|
+
acc[metric] = MetricDecision.call(metric: metric, baseline_samples: baseline_samples,
|
|
76
|
+
candidate_samples: candidate_samples, config: config)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def sample_keys(baseline_workload, candidate_workload)
|
|
81
|
+
(baseline_workload.fetch("summary", {}).keys + candidate_workload.fetch("summary", {}).keys).uniq
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def samples_for(workload, sample_key)
|
|
85
|
+
workload.fetch("samples", []).filter_map { |sample| sample[sample_key] }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def workload_decision(metrics)
|
|
89
|
+
decisions = metrics.values.map { |m| m["decision"] }
|
|
90
|
+
return "fail" if decisions.include?("fail")
|
|
91
|
+
return "warn" if decisions.include?("warn")
|
|
92
|
+
return "inconclusive" if decisions.include?("inconclusive") && decisions.all? { |d| d != "pass" }
|
|
93
|
+
|
|
94
|
+
"pass"
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Perfgate
|
|
4
|
+
class Config
|
|
5
|
+
# Default configuration values, matching the illustrative baseline.yml
|
|
6
|
+
# in spec section 11. `storage.path` intentionally uses the base
|
|
7
|
+
# directory from section 18.2's layout (`.baseline`) rather than the
|
|
8
|
+
# `.baseline/results` shown in section 11's example, since the two
|
|
9
|
+
# sections of the spec disagree and 18.2 is the more specific
|
|
10
|
+
# authority on directory layout.
|
|
11
|
+
module Defaults
|
|
12
|
+
HASH = {
|
|
13
|
+
version: 1,
|
|
14
|
+
profile: "default",
|
|
15
|
+
execution: {
|
|
16
|
+
samples: 8,
|
|
17
|
+
warmup: 2,
|
|
18
|
+
seed: 12_345,
|
|
19
|
+
order: "defined",
|
|
20
|
+
fail_fast: false,
|
|
21
|
+
isolation: "process_per_workload"
|
|
22
|
+
},
|
|
23
|
+
metrics: {
|
|
24
|
+
duration: { enabled: true },
|
|
25
|
+
sql_count: { enabled: true },
|
|
26
|
+
sql_duration: { enabled: true },
|
|
27
|
+
allocations: { enabled: true },
|
|
28
|
+
gc: { enabled: true },
|
|
29
|
+
memory: { enabled: false }
|
|
30
|
+
},
|
|
31
|
+
comparison: {
|
|
32
|
+
minimum_samples: 5,
|
|
33
|
+
confidence_level: 0.95,
|
|
34
|
+
# How much of a metric's own noise (MAD relative to its median)
|
|
35
|
+
# we tolerate before treating a would-be "fail" as unreliable
|
|
36
|
+
# and downgrading it to "warn" instead (spec 16.5). The spec
|
|
37
|
+
# calls for noise-aware downgrading but doesn't name a default
|
|
38
|
+
# ratio, so 0.5 (MAD up to half the median) was chosen as a
|
|
39
|
+
# conservative starting point pending real-world tuning.
|
|
40
|
+
noise_ratio_threshold: 0.5,
|
|
41
|
+
practical_thresholds: {
|
|
42
|
+
duration: { warning_percent: 10, failure_percent: 20, minimum_absolute_ms: 10 },
|
|
43
|
+
sql_count: { warning_absolute: 1, failure_percent: 20 },
|
|
44
|
+
sql_duration: { warning_percent: 15, failure_percent: 30, minimum_absolute_ms: 5 },
|
|
45
|
+
allocations: { warning_percent: 15, failure_percent: 30 }
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
policy: {
|
|
49
|
+
fail_on: "fail",
|
|
50
|
+
incompatible: "warn",
|
|
51
|
+
missing_baseline: "warn",
|
|
52
|
+
new_workload: "warn",
|
|
53
|
+
removed_workload: "warn"
|
|
54
|
+
},
|
|
55
|
+
fingerprint: {
|
|
56
|
+
strict: %w[
|
|
57
|
+
ruby_engine ruby_version rails_version baseline_version_major
|
|
58
|
+
database_adapter database_version_major workload_definition_hash dataset_hash
|
|
59
|
+
],
|
|
60
|
+
informational: %w[
|
|
61
|
+
operating_system cpu_model cpu_count memory_bytes
|
|
62
|
+
ci_provider runner_image dependency_lock_hash
|
|
63
|
+
]
|
|
64
|
+
},
|
|
65
|
+
storage: { adapter: "filesystem", path: ".perfgate" },
|
|
66
|
+
telemetry: { enabled: false }
|
|
67
|
+
}.freeze
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Perfgate
|
|
4
|
+
class Config
|
|
5
|
+
# Applies PERFGATE_-prefixed environment variable overrides on top of
|
|
6
|
+
# a merged configuration hash, per spec section 11 ("environment-
|
|
7
|
+
# variable overrides use a documented PERFGATE_ prefix"). Any leaf
|
|
8
|
+
# path can be overridden, e.g. PERFGATE_EXECUTION_SAMPLES=12 overrides
|
|
9
|
+
# execution.samples. The override is type-coerced based on the
|
|
10
|
+
# existing default value at that path.
|
|
11
|
+
module EnvOverrides
|
|
12
|
+
PREFIX = "PERFGATE_"
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def apply(hash, env: ENV)
|
|
17
|
+
each_leaf_path(hash) do |path, current_value|
|
|
18
|
+
env_key = PREFIX + path.join("_").upcase
|
|
19
|
+
next unless env.key?(env_key)
|
|
20
|
+
|
|
21
|
+
set_path(hash, path, coerce(env[env_key], current_value))
|
|
22
|
+
end
|
|
23
|
+
hash
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def each_leaf_path(hash, prefix = [], &block)
|
|
27
|
+
hash.each do |key, value|
|
|
28
|
+
path = prefix + [key]
|
|
29
|
+
if value.is_a?(Hash)
|
|
30
|
+
each_leaf_path(value, path, &block)
|
|
31
|
+
else
|
|
32
|
+
block.call(path, value)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def set_path(hash, path, value)
|
|
38
|
+
*init, last = path
|
|
39
|
+
target = init.reduce(hash) { |h, k| h[k] }
|
|
40
|
+
target[last] = value
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def coerce(raw, current_value)
|
|
44
|
+
case current_value
|
|
45
|
+
when Integer then Integer(raw)
|
|
46
|
+
when Float then Float(raw)
|
|
47
|
+
when true, false then %w[1 true yes on].include?(raw.downcase)
|
|
48
|
+
when Array then raw.split(",").map(&:strip)
|
|
49
|
+
else raw
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Perfgate
|
|
4
|
+
class Config
|
|
5
|
+
# Declares which keys baseline.yml recognizes at each nesting level.
|
|
6
|
+
# A Hash value means "this key has its own nested keys"; `true` marks
|
|
7
|
+
# a leaf value. Used to enforce "unknown keys fail validation" from
|
|
8
|
+
# spec section 11.
|
|
9
|
+
module Schema
|
|
10
|
+
TREE = {
|
|
11
|
+
version: true,
|
|
12
|
+
profile: true,
|
|
13
|
+
execution: {
|
|
14
|
+
samples: true,
|
|
15
|
+
warmup: true,
|
|
16
|
+
seed: true,
|
|
17
|
+
order: true,
|
|
18
|
+
fail_fast: true,
|
|
19
|
+
isolation: true
|
|
20
|
+
},
|
|
21
|
+
metrics: {
|
|
22
|
+
duration: { enabled: true },
|
|
23
|
+
sql_count: { enabled: true },
|
|
24
|
+
sql_duration: { enabled: true },
|
|
25
|
+
allocations: { enabled: true },
|
|
26
|
+
gc: { enabled: true },
|
|
27
|
+
memory: { enabled: true }
|
|
28
|
+
},
|
|
29
|
+
comparison: {
|
|
30
|
+
minimum_samples: true,
|
|
31
|
+
confidence_level: true,
|
|
32
|
+
noise_ratio_threshold: true,
|
|
33
|
+
practical_thresholds: {
|
|
34
|
+
duration: { warning_percent: true, failure_percent: true, minimum_absolute_ms: true },
|
|
35
|
+
sql_count: { warning_absolute: true, failure_percent: true },
|
|
36
|
+
sql_duration: { warning_percent: true, failure_percent: true, minimum_absolute_ms: true },
|
|
37
|
+
allocations: { warning_percent: true, failure_percent: true }
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
policy: {
|
|
41
|
+
fail_on: true,
|
|
42
|
+
incompatible: true,
|
|
43
|
+
missing_baseline: true,
|
|
44
|
+
new_workload: true,
|
|
45
|
+
removed_workload: true
|
|
46
|
+
},
|
|
47
|
+
fingerprint: { strict: true, informational: true },
|
|
48
|
+
storage: { adapter: true, path: true },
|
|
49
|
+
telemetry: { enabled: true }
|
|
50
|
+
}.freeze
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Perfgate
|
|
4
|
+
class Config
|
|
5
|
+
# Validates a merged configuration hash against Schema::TREE and a
|
|
6
|
+
# handful of range/type rules that matter for Milestone 1 (sample and
|
|
7
|
+
# warmup counts). Raises Perfgate::ConfigurationError on any problem,
|
|
8
|
+
# per spec section 11 ("unknown keys fail validation").
|
|
9
|
+
module Validator
|
|
10
|
+
MINIMUM_SAMPLES = 3
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def call(hash)
|
|
15
|
+
check_unknown_keys(hash, Schema::TREE, [])
|
|
16
|
+
check_version(hash)
|
|
17
|
+
check_execution(hash)
|
|
18
|
+
hash
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def check_unknown_keys(hash, schema, path)
|
|
22
|
+
hash.each_key do |key|
|
|
23
|
+
check_known_key(key, schema, path)
|
|
24
|
+
|
|
25
|
+
nested_schema = schema[key]
|
|
26
|
+
next unless nested_schema.is_a?(Hash)
|
|
27
|
+
|
|
28
|
+
value = hash[key]
|
|
29
|
+
unless value.is_a?(Hash)
|
|
30
|
+
raise Perfgate::ConfigurationError, "expected #{(path + [key]).join(".")} to be a mapping"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
check_unknown_keys(value, nested_schema, path + [key])
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def check_known_key(key, schema, path)
|
|
38
|
+
return if schema.key?(key)
|
|
39
|
+
|
|
40
|
+
raise Perfgate::ConfigurationError, "unknown configuration key: #{(path + [key]).join(".")}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def check_version(hash)
|
|
44
|
+
return if hash[:version] == 1
|
|
45
|
+
|
|
46
|
+
raise Perfgate::ConfigurationError, "unsupported configuration version: #{hash[:version].inspect} (expected 1)"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def check_execution(hash)
|
|
50
|
+
samples = hash.dig(:execution, :samples)
|
|
51
|
+
warmup = hash.dig(:execution, :warmup)
|
|
52
|
+
|
|
53
|
+
if samples && (!samples.is_a?(Integer) || samples < MINIMUM_SAMPLES)
|
|
54
|
+
raise Perfgate::ConfigurationError,
|
|
55
|
+
"execution.samples must be an integer >= #{MINIMUM_SAMPLES}, got #{samples.inspect}"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
return unless warmup && (!warmup.is_a?(Integer) || warmup.negative?)
|
|
59
|
+
|
|
60
|
+
raise Perfgate::ConfigurationError, "execution.warmup must be a non-negative integer, got #{warmup.inspect}"
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require_relative "config/defaults"
|
|
5
|
+
require_relative "config/schema"
|
|
6
|
+
require_relative "config/validator"
|
|
7
|
+
require_relative "config/env_overrides"
|
|
8
|
+
|
|
9
|
+
module Perfgate
|
|
10
|
+
# Loads, validates, and provides typed access to baseline.yml (spec
|
|
11
|
+
# section 11). Configuration is a plain merged Hash under the hood;
|
|
12
|
+
# this class only adds convenience readers for the values Milestone 1
|
|
13
|
+
# actually acts on (execution sample/warmup counts, enabled metrics,
|
|
14
|
+
# storage path). Later milestones will add readers for comparison,
|
|
15
|
+
# policy, and fingerprint sections as those are implemented.
|
|
16
|
+
class Config
|
|
17
|
+
class << self
|
|
18
|
+
# A Config built entirely from defaults, with no file on disk.
|
|
19
|
+
def default
|
|
20
|
+
new(deep_dup(Defaults::HASH))
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Loads baseline.yml (or the given path). A missing file is treated
|
|
24
|
+
# as an empty configuration, i.e. pure defaults.
|
|
25
|
+
def load(path = "perfgate.yml")
|
|
26
|
+
raw = read_yaml(path)
|
|
27
|
+
merged = deep_merge(deep_dup(Defaults::HASH), raw)
|
|
28
|
+
EnvOverrides.apply(merged)
|
|
29
|
+
Validator.call(merged)
|
|
30
|
+
new(merged)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def read_yaml(path)
|
|
36
|
+
return {} unless File.exist?(path)
|
|
37
|
+
|
|
38
|
+
content = YAML.safe_load_file(path, permitted_classes: [Symbol], symbolize_names: true)
|
|
39
|
+
content || {}
|
|
40
|
+
rescue Psych::SyntaxError => e
|
|
41
|
+
raise Perfgate::ConfigurationError, "invalid YAML in #{path}: #{e.message}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# A small recursive dup that avoids Marshal (forbidden project-wide
|
|
45
|
+
# for serialization) while still deep-copying nested hashes/arrays
|
|
46
|
+
# so mutating a loaded Config never mutates the frozen defaults.
|
|
47
|
+
def deep_dup(value)
|
|
48
|
+
case value
|
|
49
|
+
when Hash
|
|
50
|
+
value.each_with_object({}) { |(k, v), acc| acc[k] = deep_dup(v) }
|
|
51
|
+
when Array
|
|
52
|
+
value.map { |v| deep_dup(v) }
|
|
53
|
+
else
|
|
54
|
+
value
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def deep_merge(base, override)
|
|
59
|
+
base.merge(override) do |_key, base_val, override_val|
|
|
60
|
+
if base_val.is_a?(Hash) && override_val.is_a?(Hash)
|
|
61
|
+
deep_merge(base_val, override_val)
|
|
62
|
+
else
|
|
63
|
+
override_val
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The default dataset fingerprint hook (spec section 12.3): apps that
|
|
70
|
+
# care about dataset drift affecting comparability can override this
|
|
71
|
+
# with a callable of their own via `Perfgate.configure`.
|
|
72
|
+
DEFAULT_DATASET_FINGERPRINT = -> { ENV.fetch("PERFGATE_DATASET_VERSION", "unspecified") }
|
|
73
|
+
|
|
74
|
+
attr_reader :to_h
|
|
75
|
+
attr_accessor :dataset_fingerprint
|
|
76
|
+
|
|
77
|
+
def initialize(hash)
|
|
78
|
+
@to_h = hash
|
|
79
|
+
@dataset_fingerprint = DEFAULT_DATASET_FINGERPRINT
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def execution_samples
|
|
83
|
+
to_h.dig(:execution, :samples)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def execution_warmup
|
|
87
|
+
to_h.dig(:execution, :warmup)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def enabled_metrics
|
|
91
|
+
to_h.fetch(:metrics).select { |_name, opts| opts[:enabled] }.keys
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Defaults handed to newly-discovered workloads that don't override
|
|
95
|
+
# samples/warmup/metrics themselves (spec section 9.3).
|
|
96
|
+
def execution_defaults
|
|
97
|
+
{ samples: execution_samples, warmup: execution_warmup, metrics: enabled_metrics }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def storage_path
|
|
101
|
+
to_h.dig(:storage, :path)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def comparison_minimum_samples
|
|
105
|
+
to_h.dig(:comparison, :minimum_samples)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def comparison_confidence_level
|
|
109
|
+
to_h.dig(:comparison, :confidence_level)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def comparison_noise_ratio_threshold
|
|
113
|
+
to_h.dig(:comparison, :noise_ratio_threshold)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def practical_thresholds
|
|
117
|
+
to_h.dig(:comparison, :practical_thresholds)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def fingerprint_strict_fields
|
|
121
|
+
to_h.dig(:fingerprint, :strict)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def fingerprint_informational_fields
|
|
125
|
+
to_h.dig(:fingerprint, :informational)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def policy
|
|
129
|
+
to_h.fetch(:policy)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def dig(*keys)
|
|
133
|
+
to_h.dig(*keys)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Perfgate
|
|
4
|
+
# Base class for all Baseline-raised errors.
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Raised when baseline.yml is missing, malformed, or fails schema validation.
|
|
8
|
+
class ConfigurationError < Error; end
|
|
9
|
+
|
|
10
|
+
# Raised when a workload cannot be executed as configured (e.g. missing
|
|
11
|
+
# RSpec example, invalid metadata, duplicate workload id).
|
|
12
|
+
class WorkloadError < Error; end
|
|
13
|
+
|
|
14
|
+
# Raised when a candidate and baseline run cannot be safely compared.
|
|
15
|
+
class IncompatibleRunError < Error; end
|
|
16
|
+
|
|
17
|
+
# Raised when a result bundle is malformed, tampered with, or fails
|
|
18
|
+
# checksum verification.
|
|
19
|
+
class ResultBundleError < Error; end
|
|
20
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "runner"
|
|
5
|
+
|
|
6
|
+
module Perfgate
|
|
7
|
+
module Execution
|
|
8
|
+
# Runs a workload's warmup + samples inside a fresh child process
|
|
9
|
+
# (spec section 12.1: default "process_per_workload" isolation). The
|
|
10
|
+
# child relays its result to the parent as JSON over a pipe -- Marshal
|
|
11
|
+
# is deliberately avoided per the project's cross-process
|
|
12
|
+
# serialization policy (data crossing a process boundary must not be
|
|
13
|
+
# able to instantiate arbitrary Ruby objects).
|
|
14
|
+
#
|
|
15
|
+
# Note for database-backed workloads: an in-memory SQLite database
|
|
16
|
+
# does not survive fork (each child effectively starts with an empty
|
|
17
|
+
# database), so process isolation requires a file-based or
|
|
18
|
+
# server-based test database. This mirrors an existing constraint on
|
|
19
|
+
# Rails' own parallel test runners and isn't specific to Baseline.
|
|
20
|
+
class ProcessRunner
|
|
21
|
+
def initialize(workload, runner_class: Runner)
|
|
22
|
+
@workload = workload
|
|
23
|
+
@runner_class = runner_class
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def call
|
|
27
|
+
ensure_fork_supported!
|
|
28
|
+
|
|
29
|
+
reader, writer = IO.pipe
|
|
30
|
+
pid = fork_child(reader, writer)
|
|
31
|
+
writer.close
|
|
32
|
+
payload = reader.read
|
|
33
|
+
reader.close
|
|
34
|
+
_pid, status = Process.waitpid2(pid)
|
|
35
|
+
|
|
36
|
+
parse_result(payload, status)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def ensure_fork_supported!
|
|
42
|
+
return if Process.respond_to?(:fork)
|
|
43
|
+
|
|
44
|
+
raise Perfgate::Error, "process isolation requires Process.fork, which this Ruby platform does not support"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def fork_child(reader, writer)
|
|
48
|
+
Process.fork do
|
|
49
|
+
reader.close
|
|
50
|
+
result = @runner_class.new(@workload).call
|
|
51
|
+
writer.write(JSON.generate(result))
|
|
52
|
+
writer.close
|
|
53
|
+
exit!(0)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def parse_result(payload, status)
|
|
58
|
+
if payload.nil? || payload.empty?
|
|
59
|
+
{
|
|
60
|
+
"id" => @workload.id,
|
|
61
|
+
"status" => "error",
|
|
62
|
+
"samples" => [],
|
|
63
|
+
"error" => "workload process exited without a result (exit status #{status.exitstatus})"
|
|
64
|
+
}
|
|
65
|
+
else
|
|
66
|
+
JSON.parse(payload)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|