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.
Files changed (76) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +25 -0
  4. data/CHANGELOG.md +18 -0
  5. data/CONTRIBUTING.md +73 -0
  6. data/LICENSE +201 -0
  7. data/NOT_FINALIZED.md +70 -0
  8. data/README.md +86 -0
  9. data/ROADMAP.md +125 -0
  10. data/Rakefile +12 -0
  11. data/SECURITY.md +63 -0
  12. data/docs/README.md +7 -0
  13. data/docs/architecture.md +125 -0
  14. data/docs/compatibility.md +49 -0
  15. data/docs/launch-article.md +97 -0
  16. data/docs/onboarding.md +122 -0
  17. data/docs/telemetry.md +81 -0
  18. data/examples/rails-rspec-app/.github/workflows/baseline.yml +48 -0
  19. data/examples/rails-rspec-app/README.md +38 -0
  20. data/examples/rails-rspec-app/spec/jobs/invoice_job_spec.rb +15 -0
  21. data/examples/rails-rspec-app/spec/requests/checkout_spec.rb +24 -0
  22. data/exe/perfgate +7 -0
  23. data/lib/perfgate/cli/compare_command.rb +91 -0
  24. data/lib/perfgate/cli/run_command.rb +123 -0
  25. data/lib/perfgate/cli/run_comparison_reporter.rb +77 -0
  26. data/lib/perfgate/cli.rb +60 -0
  27. data/lib/perfgate/comparison/deterministic_metric_decision.rb +34 -0
  28. data/lib/perfgate/comparison/diagnostics.rb +70 -0
  29. data/lib/perfgate/comparison/engine.rb +79 -0
  30. data/lib/perfgate/comparison/metric_change.rb +84 -0
  31. data/lib/perfgate/comparison/metric_decision.rb +39 -0
  32. data/lib/perfgate/comparison/statistical_metric_decision.rb +75 -0
  33. data/lib/perfgate/comparison/workload_comparison.rb +98 -0
  34. data/lib/perfgate/config/defaults.rb +70 -0
  35. data/lib/perfgate/config/env_overrides.rb +54 -0
  36. data/lib/perfgate/config/schema.rb +53 -0
  37. data/lib/perfgate/config/validator.rb +64 -0
  38. data/lib/perfgate/config.rb +136 -0
  39. data/lib/perfgate/errors.rb +20 -0
  40. data/lib/perfgate/execution/process_runner.rb +71 -0
  41. data/lib/perfgate/execution/runner.rb +60 -0
  42. data/lib/perfgate/execution/sample_context.rb +66 -0
  43. data/lib/perfgate/fingerprints/compatibility.rb +46 -0
  44. data/lib/perfgate/fingerprints/components.rb +98 -0
  45. data/lib/perfgate/fingerprints/workload_definition.rb +30 -0
  46. data/lib/perfgate/instrumentation/allocations.rb +20 -0
  47. data/lib/perfgate/instrumentation/duration.rb +20 -0
  48. data/lib/perfgate/instrumentation/gc.rb +30 -0
  49. data/lib/perfgate/instrumentation/sql_activity.rb +50 -0
  50. data/lib/perfgate/instrumentation.rb +36 -0
  51. data/lib/perfgate/metrics/.gitkeep +0 -0
  52. data/lib/perfgate/policy/engine.rb +73 -0
  53. data/lib/perfgate/rails/.gitkeep +0 -0
  54. data/lib/perfgate/report/console.rb +57 -0
  55. data/lib/perfgate/report/markdown.rb +101 -0
  56. data/lib/perfgate/reporting/.gitkeep +0 -0
  57. data/lib/perfgate/rspec/discovery.rb +31 -0
  58. data/lib/perfgate/rspec/id_resolver.rb +30 -0
  59. data/lib/perfgate/rspec/workload_builder.rb +48 -0
  60. data/lib/perfgate/rspec.rb +14 -0
  61. data/lib/perfgate/serialization/run_result.rb +58 -0
  62. data/lib/perfgate/statistics/mann_whitney_u.rb +84 -0
  63. data/lib/perfgate/statistics/summary.rb +60 -0
  64. data/lib/perfgate/storage/adapter.rb +24 -0
  65. data/lib/perfgate/storage/archive.rb +69 -0
  66. data/lib/perfgate/storage/filesystem.rb +121 -0
  67. data/lib/perfgate/telemetry/.gitkeep +0 -0
  68. data/lib/perfgate/version.rb +5 -0
  69. data/lib/perfgate/workloads/registry.rb +50 -0
  70. data/lib/perfgate/workloads/workload.rb +26 -0
  71. data/lib/perfgate.rb +46 -0
  72. data/perfgate.gemspec +41 -0
  73. data/schemas/comparison-result-v1.schema.json +7 -0
  74. data/schemas/run-result-v1.schema.json +7 -0
  75. data/sig/perfgate.rbs +4 -0
  76. 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