hecks 2.5.1 → 2.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/hecks/adapters/driven/google_authentication.rb +1 -1
- data/lib/hecks/adapters/driven/lambda/client.rb +1 -1
- data/lib/hecks/adapters/driven/prism.rb +1 -1
- data/lib/hecks/bench/cli.rb +74 -0
- data/lib/hecks/bench/environment.rb +100 -0
- data/lib/hecks/bench/postgres_probe.rb +41 -0
- data/lib/hecks/bench/report.rb +116 -0
- data/lib/hecks/bench/ruby_runner.rb +151 -0
- data/lib/hecks/bench/run.rb +55 -0
- data/lib/hecks/bench/rust_runner.rb +165 -0
- data/lib/hecks/bench/stats.rb +75 -0
- data/lib/hecks/bench/suite.rb +172 -0
- data/lib/hecks/bench/workload.rb +137 -0
- data/lib/hecks/bench.rb +28 -0
- data/lib/hecks/bluebook/dsl/bootstrap_table.rb +2 -0
- data/lib/hecks/bluebook/dsl/command_builder.rb +3 -3
- data/lib/hecks/bluebook/dsl/port_operation_builder.rb +1 -1
- data/lib/hecks/bluebook/dsl/world_builder.rb +42 -3
- data/lib/hecks/bluebook/hexagon.rb +15 -7
- data/lib/hecks/bluebook/meta_validator/world_judge.rb +3 -1
- data/lib/hecks/bluebook/model_check/client_profile.rb +153 -0
- data/lib/hecks/bluebook/model_check.rb +21 -2
- data/lib/hecks/embryonaut_bluebook/lock.rb +72 -0
- data/lib/hecks/embryonaut_bluebook/shape.rb +47 -0
- data/lib/hecks/embryonaut_bluebook/vendor.rb +194 -0
- data/lib/hecks/embryonaut_bluebook/vendor_cli.rb +88 -0
- data/lib/hecks/embryonaut_bluebook.rb +29 -3
- data/lib/hecks/forms/value_object_shape.rb +1 -1
- data/lib/hecks/framework/bluebook/console_settings.bluebook +3 -3
- data/lib/hecks/fuzzing/era_boundary.rb +1 -1
- data/lib/hecks/fuzzing/isolated_boot.rb +136 -69
- data/lib/hecks/language/world/world.bluebook +32 -2
- data/lib/hecks/mcp_stdio_guard.rb +160 -0
- data/lib/hecks/ports/key_vault.rb +1 -1
- data/lib/hecks/ports/persistence/binding_policy.rb +16 -5
- data/lib/hecks/ports/persistence/plugins/era/era_check.rb +2 -1
- data/lib/hecks/ports/persistence/plugins/era/expected_era.rb +132 -0
- data/lib/hecks/ports/persistence/plugins/era/lineage.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era/translation/rule_compiler.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era.rb +7 -4
- data/lib/hecks/ports/persistence/postgres_dump/connection.rb +73 -0
- data/lib/hecks/ports/persistence/postgres_dump.rb +125 -0
- data/lib/hecks/ports/persistence/repository_factory.rb +2 -2
- data/lib/hecks/ports/persistence.rb +4 -0
- data/lib/hecks/projections/deploy/fargate/assembly.rb +193 -0
- data/lib/hecks/projections/deploy/fargate/cdn.rb +318 -0
- data/lib/hecks/projections/deploy/fargate/cdn_default.rb +87 -0
- data/lib/hecks/projections/deploy/fargate/check.rb +172 -0
- data/lib/hecks/projections/deploy/fargate/containers.rb +412 -0
- data/lib/hecks/projections/deploy/fargate/extras.rb +268 -0
- data/lib/hecks/projections/deploy/fargate/monitoring.rb +448 -0
- data/lib/hecks/projections/deploy/fargate/settings.rb +235 -0
- data/lib/hecks/projections/deploy/fargate/yaml.rb +116 -0
- data/lib/hecks/projections/deploy/fargate.rb +187 -138
- data/lib/hecks/projections/deploy/lambda.rb +5 -5
- data/lib/hecks/projections/deploy/preview/access_yaml.rb +145 -0
- data/lib/hecks/projections/deploy/preview/containers.rb +218 -0
- data/lib/hecks/projections/deploy/preview/preview.sh.erb +446 -0
- data/lib/hecks/projections/deploy/preview/routing_yaml.rb +216 -0
- data/lib/hecks/projections/deploy/preview/script.rb +40 -0
- data/lib/hecks/projections/deploy/preview/settings.rb +140 -0
- data/lib/hecks/projections/deploy/preview/task_yaml.rb +215 -0
- data/lib/hecks/projections/deploy/preview/template.rb +247 -0
- data/lib/hecks/projections/deploy/preview/yaml_text.rb +48 -0
- data/lib/hecks/projections/deploy/preview.rb +103 -0
- data/lib/hecks/projections/deploy/scripts/deploy-service.sh.tmpl +182 -0
- data/lib/hecks/projections/deploy/scripts/expected-era.header +17 -0
- data/lib/hecks/projections/deploy/scripts/hosting.mk.tmpl +54 -0
- data/lib/hecks/projections/deploy/scripts/settings.rb +114 -0
- data/lib/hecks/projections/deploy/scripts/smoke-after-deploy.sh.tmpl +232 -0
- data/lib/hecks/projections/deploy/scripts.rb +171 -0
- data/lib/hecks/projections/deploy/smoke/harness.js +366 -0
- data/lib/hecks/projections/deploy/smoke/workflow.yml.tmpl +56 -0
- data/lib/hecks/projections/deploy/smoke.rb +159 -0
- data/lib/hecks/projections/deploy/template_diff/comparer.rb +231 -0
- data/lib/hecks/projections/deploy/template_diff/loader.rb +105 -0
- data/lib/hecks/projections/deploy/template_diff.rb +173 -0
- data/lib/hecks/projections/glossary/sensitivity.rb +1 -1
- data/lib/hecks/projections.rb +2 -0
- data/lib/hecks/release/gem_pin/published_versions.rb +58 -0
- data/lib/hecks/release/gem_pin.rb +112 -0
- data/lib/hecks/runtime/command_rules/arithmetic.rb +2 -2
- data/lib/hecks/runtime/loader.rb +26 -2
- data/lib/hecks/runtime/registry/saga_persistence.rb +8 -2
- data/lib/hecks/runtime/registry/verification.rb +4 -2
- data/lib/hecks/runtime/registry/world_defaults.rb +106 -0
- data/lib/hecks/runtime/registry.rb +14 -9
- data/lib/hecks/runtime/tenant_check.rb +1 -1
- data/lib/hecks/vendoring/git_environment.rb +35 -0
- data/lib/hecks/vendoring/git_source.rb +102 -0
- data/lib/hecks/vendoring.rb +102 -0
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks.rb +3 -1
- metadata +59 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a91dfe0fb86ceb9bb96ecc6b08da680213fed526f49f922f1a2f66d9cb89c80e
|
|
4
|
+
data.tar.gz: 22fc90a2e361f45e27796248c22391997281e62fbfeec01b25ba35378e6e5681
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 824fd1d23971f6dacfd3c3ea18a35f5d0d61e7f07e2056b789d7be7d105a7bc47d13a61e0391d7aeb46d20bcfe8400e307bf05728ed10f9e4c69b46ac7d0df45
|
|
7
|
+
data.tar.gz: 3f6e4679c8187f212f913de1d17944c3bac2ed0e4385bcff3bcd259e1b6ccb49d6c8270c7f84885fcc3f308e9ca7def653341050c649d1a048b8a91228ee65e7
|
|
@@ -6,7 +6,7 @@ module Hecks
|
|
|
6
6
|
module Adapters
|
|
7
7
|
# **Google's own OIDC handshake** — the `authentication` port's one real
|
|
8
8
|
# implementation today. Consolidates what was hand-rolled per-app
|
|
9
|
-
# (
|
|
9
|
+
# (a console app's `google_auth.rb` did this on its own) into
|
|
10
10
|
# one adapter, so any hecks-based app gets Google sign-in for free, the
|
|
11
11
|
# same "one adapter, reusable everywhere" value every other adapter
|
|
12
12
|
# in this directory already has.
|
|
@@ -29,7 +29,7 @@ module Hecks
|
|
|
29
29
|
# exist precisely so a domain whose AWS identity predates a rename
|
|
30
30
|
# keeps targeting the stack that is actually live rather than
|
|
31
31
|
# standing up a second, empty one beside it. A real one:
|
|
32
|
-
#
|
|
32
|
+
# a client site deploys under a fixed stack name, and no
|
|
33
33
|
# value of `domain` (or of `DOMAIN_NAME`, the deployed-Lambda
|
|
34
34
|
# override callers already pass) can make this computation produce
|
|
35
35
|
# a name with no dash after "hecks". Every Ruby-side read and
|
|
@@ -72,7 +72,7 @@ module Hecks
|
|
|
72
72
|
# That's invisible to anything that virtualizes the filesystem at
|
|
73
73
|
# the Ruby level instead of the OS level (e.g. tebako's memfs,
|
|
74
74
|
# which presses a hecks-based app into a single executable —
|
|
75
|
-
# see domain/README.md's "Deploying" section in
|
|
75
|
+
# see domain/README.md's "Deploying" section in a client site's domain for
|
|
76
76
|
# why that matters). ::Prism.parse(File.read(file)) parses the
|
|
77
77
|
# exact same bytes, just read through Ruby's File.read first,
|
|
78
78
|
# which those tools do intercept.
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
require "optparse"
|
|
2
|
+
require_relative "suite"
|
|
3
|
+
require_relative "report"
|
|
4
|
+
|
|
5
|
+
module Hecks
|
|
6
|
+
module Bench
|
|
7
|
+
# The command line behind `bin/bench`: parses flags, runs the `Suite`, prints the report.
|
|
8
|
+
#
|
|
9
|
+
# Kept in `lib/` so a spec can drive it with a tiny configuration and read what it
|
|
10
|
+
# prints, rather than shelling out to a script.
|
|
11
|
+
module CLI
|
|
12
|
+
# The defaults `bin/bench` runs with when no flag says otherwise.
|
|
13
|
+
DEFAULTS = { domains: %w[pizzas banking], targets: Suite::TARGETS, warmup: 200, iterations: 1000,
|
|
14
|
+
runs: 3 }.freeze
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
# Runs the benchmark described by the command line and prints its report.
|
|
19
|
+
#
|
|
20
|
+
# @param argv [Array<String>] the command-line arguments
|
|
21
|
+
# @param out [#puts] where the report goes
|
|
22
|
+
# @param err [#puts] where progress and usage errors go
|
|
23
|
+
# @return [Integer] the process exit status: 0 on success, 2 for a usage error
|
|
24
|
+
def run(argv, out: $stdout, err: $stderr)
|
|
25
|
+
options = parse(argv)
|
|
26
|
+
result = Suite.call(options.fetch(:config), log: err)
|
|
27
|
+
out.puts(options[:format] == "json" ? Report.json(result) : Report.markdown(result))
|
|
28
|
+
File.write(options[:output], Report.json(result)) if options[:output]
|
|
29
|
+
0
|
|
30
|
+
rescue OptionParser::ParseError, ArgumentError => e
|
|
31
|
+
err.puts "bin/bench: #{e.message}"
|
|
32
|
+
2
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Turns command-line arguments into a configuration and output choices.
|
|
36
|
+
#
|
|
37
|
+
# @param argv [Array<String>] the command-line arguments
|
|
38
|
+
# @return [Hash{Symbol => Object}] `:config` (a `Suite::Config`), `:format` and `:output`
|
|
39
|
+
# @raise [OptionParser::ParseError] on an unknown or malformed flag
|
|
40
|
+
def parse(argv)
|
|
41
|
+
values = DEFAULTS.dup
|
|
42
|
+
extras = { format: "markdown", output: nil, rust_binary: nil }
|
|
43
|
+
parser(values, extras).parse(argv.dup)
|
|
44
|
+
{ config: Suite::Config.new(**values, rust_binary: extras[:rust_binary]),
|
|
45
|
+
format: extras[:format], output: extras[:output] }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Builds the flag parser.
|
|
49
|
+
#
|
|
50
|
+
# @param values [Hash{Symbol => Object}] the `Suite::Config` fields, filled in as flags parse
|
|
51
|
+
# @param extras [Hash{Symbol => Object}] the output choices, filled in as flags parse
|
|
52
|
+
# @return [OptionParser] the parser
|
|
53
|
+
def parser(values, extras)
|
|
54
|
+
OptionParser.new do |opts|
|
|
55
|
+
opts.banner = "usage: bin/bench [options]"
|
|
56
|
+
opts.on("--domain NAMES", Array, "pizzas, banking (default: both)") { |v| values[:domains] = v }
|
|
57
|
+
opts.on("--targets NAMES", Array, "#{Suite::TARGETS.join(', ')} (default: all)") { |v| values[:targets] = v }
|
|
58
|
+
opts.on("--iterations N", Integer, "timed cycles per run (default #{DEFAULTS[:iterations]})") do |v|
|
|
59
|
+
values[:iterations] = v
|
|
60
|
+
end
|
|
61
|
+
opts.on("--warmup N", Integer, "discarded cycles before timing (default #{DEFAULTS[:warmup]})") do |v|
|
|
62
|
+
values[:warmup] = v
|
|
63
|
+
end
|
|
64
|
+
opts.on("--runs N", Integer, "fresh boots per target, median reported (default #{DEFAULTS[:runs]})") do |v|
|
|
65
|
+
values[:runs] = v
|
|
66
|
+
end
|
|
67
|
+
opts.on("--rust-binary PATH", "use this `rust` binary instead of building one") { |v| extras[:rust_binary] = v }
|
|
68
|
+
opts.on("--format FORMAT", %w[markdown json], "markdown (default) or json") { |v| extras[:format] = v }
|
|
69
|
+
opts.on("--output PATH", "also write the full JSON result here") { |v| extras[:output] = v }
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
require "etc"
|
|
2
|
+
require "open3"
|
|
3
|
+
|
|
4
|
+
module Hecks
|
|
5
|
+
module Bench
|
|
6
|
+
# A description of the machine and toolchain a benchmark ran on.
|
|
7
|
+
#
|
|
8
|
+
# A number without its hardware is not reproducible, so every report carries
|
|
9
|
+
# this. Each probe is best effort: a missing tool becomes `"unknown"` rather
|
|
10
|
+
# than an error, since the benchmark itself is still valid without it.
|
|
11
|
+
module Environment
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# Collects the machine and toolchain facts a report should carry.
|
|
15
|
+
#
|
|
16
|
+
# @return [Hash{Symbol => Object}] `:cpu`, `:cores`, `:memory_gib`, `:os`, `:ruby`,
|
|
17
|
+
# `:yjit`, `:rustc`, `:cargo`, `:hecks`, `:commit` and `:load_average`
|
|
18
|
+
def describe
|
|
19
|
+
{
|
|
20
|
+
cpu: cpu, cores: Etc.nprocessors, memory_gib: memory_gib, os: os,
|
|
21
|
+
ruby: RUBY_DESCRIPTION, yjit: yjit?,
|
|
22
|
+
rustc: capture("rustc", "-V"), cargo: capture("cargo", "-V"),
|
|
23
|
+
hecks: Hecks::VERSION, commit: commit, load_average: load_average
|
|
24
|
+
}
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Reads the one-minute load average, the quickest sign that something else was
|
|
28
|
+
# busy while a benchmark ran. `Suite.call` reads it before and after and reports both.
|
|
29
|
+
#
|
|
30
|
+
# @return [Float, nil] the one-minute load average, or nil when it cannot be read
|
|
31
|
+
def load_average
|
|
32
|
+
text = RUBY_PLATFORM.include?("darwin") ? capture("sysctl", "-n", "vm.loadavg").delete("{}") : File.read("/proc/loadavg")
|
|
33
|
+
text.split.first.to_f.round(2)
|
|
34
|
+
rescue SystemCallError
|
|
35
|
+
nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Names the processor.
|
|
39
|
+
#
|
|
40
|
+
# @return [String] the CPU model, or `"unknown"`
|
|
41
|
+
def cpu
|
|
42
|
+
return capture("sysctl", "-n", "machdep.cpu.brand_string") if RUBY_PLATFORM.include?("darwin")
|
|
43
|
+
|
|
44
|
+
model = File.foreach("/proc/cpuinfo").find { |line| line.start_with?("model name") }
|
|
45
|
+
model ? model.split(":", 2).last.strip : "unknown"
|
|
46
|
+
rescue SystemCallError
|
|
47
|
+
"unknown"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Reads installed memory.
|
|
51
|
+
#
|
|
52
|
+
# @return [Float, String] physical memory in GiB, or `"unknown"`
|
|
53
|
+
def memory_gib
|
|
54
|
+
bytes =
|
|
55
|
+
if RUBY_PLATFORM.include?("darwin")
|
|
56
|
+
capture("sysctl", "-n", "hw.memsize").to_i
|
|
57
|
+
else
|
|
58
|
+
File.read("/proc/meminfo")[/MemTotal:\s+(\d+)/, 1].to_i * 1024
|
|
59
|
+
end
|
|
60
|
+
bytes.zero? ? "unknown" : (bytes / (1024.0**3)).round(1)
|
|
61
|
+
rescue SystemCallError
|
|
62
|
+
"unknown"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Names the operating system.
|
|
66
|
+
#
|
|
67
|
+
# @return [String] the OS name and version, or `"unknown"`
|
|
68
|
+
def os
|
|
69
|
+
return "macOS #{capture('sw_vers', '-productVersion')} (#{capture('uname', '-m')})" if RUBY_PLATFORM.include?("darwin")
|
|
70
|
+
|
|
71
|
+
capture("uname", "-sr")
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Says whether Ruby's `YJIT` compiler was running.
|
|
75
|
+
#
|
|
76
|
+
# @return [Boolean] true when `YJIT` is enabled in this process
|
|
77
|
+
def yjit?
|
|
78
|
+
defined?(RubyVM::YJIT) ? RubyVM::YJIT.enabled? : false
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Names the Hecks source revision under test.
|
|
82
|
+
#
|
|
83
|
+
# @return [String] the short commit hash, or `"unknown"` outside a git checkout
|
|
84
|
+
def commit
|
|
85
|
+
capture("git", "-C", File.expand_path("../../..", __dir__), "rev-parse", "--short", "HEAD")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Runs a command and returns its first line of output.
|
|
89
|
+
#
|
|
90
|
+
# @param command [Array<String>] the program and its arguments
|
|
91
|
+
# @return [String] the trimmed first line of stdout, or `"unknown"` if it cannot run
|
|
92
|
+
def capture(*command)
|
|
93
|
+
out, status = Open3.capture2(*command, err: File::NULL)
|
|
94
|
+
status.success? && !out.strip.empty? ? out.lines.first.strip : "unknown"
|
|
95
|
+
rescue SystemCallError
|
|
96
|
+
"unknown"
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
module Hecks
|
|
2
|
+
module Bench
|
|
3
|
+
# Decides whether the Postgres targets can run at all.
|
|
4
|
+
#
|
|
5
|
+
# Postgres is optional. A machine with no server, or without the `pg` gem, gets a
|
|
6
|
+
# skipped target and a sentence saying why, never a stack trace. Connection
|
|
7
|
+
# settings come from libpq's own environment (`PGHOST`, `PGPORT`, `PGUSER`,
|
|
8
|
+
# `PGPASSWORD`), the same way the fuzzer's Postgres adapters read them.
|
|
9
|
+
module PostgresProbe
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# Tries to reach the server's maintenance database.
|
|
13
|
+
#
|
|
14
|
+
# @return [String, nil] nil when a server answered, otherwise one sentence saying why
|
|
15
|
+
# the Postgres targets cannot run
|
|
16
|
+
def unavailable_reason
|
|
17
|
+
require "pg"
|
|
18
|
+
PG.connect(dbname: "postgres", connect_timeout: 2).close
|
|
19
|
+
nil
|
|
20
|
+
rescue LoadError
|
|
21
|
+
"the `pg` gem is not installed"
|
|
22
|
+
rescue PG::Error => e
|
|
23
|
+
"no Postgres server is reachable (#{e.message.lines.first.to_s.strip}); start one, or point " \
|
|
24
|
+
"PGHOST, PGPORT and PGUSER at one"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Reads the server's version for the report's environment block.
|
|
28
|
+
#
|
|
29
|
+
# @return [String] the `server_version` setting, or `"unknown"` if it cannot be read
|
|
30
|
+
def server_version
|
|
31
|
+
require "pg"
|
|
32
|
+
connection = PG.connect(dbname: "postgres", connect_timeout: 2)
|
|
33
|
+
connection.exec("SHOW server_version").getvalue(0, 0)
|
|
34
|
+
rescue LoadError, PG::Error
|
|
35
|
+
"unknown"
|
|
36
|
+
ensure
|
|
37
|
+
connection&.close
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module Hecks
|
|
4
|
+
module Bench
|
|
5
|
+
# Renders a `Suite.call` result as Markdown or JSON.
|
|
6
|
+
#
|
|
7
|
+
# The Markdown is what `docs/benchmarks.md` pastes in. It states the machine, the
|
|
8
|
+
# counts and the skipped targets next to the table, so a copied table is never a
|
|
9
|
+
# bare number.
|
|
10
|
+
module Report
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# Renders the whole report as Markdown.
|
|
14
|
+
#
|
|
15
|
+
# @param result [Hash] the value `Suite.call` returned
|
|
16
|
+
# @return [String] the environment, the configuration, the table and any skipped targets
|
|
17
|
+
def markdown(result)
|
|
18
|
+
[environment_block(result[:environment]), configuration_block(result[:config]),
|
|
19
|
+
table(result[:results]), floor_block(result[:results]), skipped_block(result[:skipped])]
|
|
20
|
+
.compact.join("\n\n") << "\n"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Renders the whole report as JSON.
|
|
24
|
+
#
|
|
25
|
+
# @param result [Hash] the value `Suite.call` returned
|
|
26
|
+
# @return [String] pretty-printed JSON of `result`
|
|
27
|
+
def json(result)
|
|
28
|
+
JSON.pretty_generate(result)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Renders the results table.
|
|
32
|
+
#
|
|
33
|
+
# @param results [Array<Hash>] the `:results` of `Suite.call`
|
|
34
|
+
# @return [String] a Markdown table with one row per domain and target
|
|
35
|
+
def table(results)
|
|
36
|
+
header = ["| domain | target | commands/s | range across runs | p50 (us) | p99 (us) | drift |",
|
|
37
|
+
"|---|---|---:|---:|---:|---:|---:|"]
|
|
38
|
+
(header + results.map { |entry| row(entry) }).join("\n")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Renders one table row.
|
|
42
|
+
#
|
|
43
|
+
# @param entry [Hash] one aggregated result
|
|
44
|
+
# @return [String] the row as Markdown
|
|
45
|
+
def row(entry)
|
|
46
|
+
median = entry[:median]
|
|
47
|
+
low, high = entry[:throughput_range]
|
|
48
|
+
cells = [entry[:domain], entry[:target], number(median[:throughput_per_s], 0),
|
|
49
|
+
"#{number(low, 0)} to #{number(high, 0)}", number(median[:p50_us], 1),
|
|
50
|
+
number(median[:p99_us], 1), number(median[:drift], 2)]
|
|
51
|
+
"| #{cells.join(' | ')} |"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Formats a number with thousands separators.
|
|
55
|
+
#
|
|
56
|
+
# @param value [Numeric, nil] the number
|
|
57
|
+
# @param places [Integer] digits after the decimal point
|
|
58
|
+
# @return [String] the formatted number, or `"n/a"` for nil
|
|
59
|
+
def number(value, places)
|
|
60
|
+
return "n/a" if value.nil?
|
|
61
|
+
|
|
62
|
+
whole, fraction = format("%.#{places}f", value).split(".")
|
|
63
|
+
[whole.gsub(/(\d)(?=(\d{3})+\z)/, '\1,'), fraction].compact.join(".")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Renders the machine and toolchain.
|
|
67
|
+
#
|
|
68
|
+
# @param environment [Hash] the `:environment` of `Suite.call`
|
|
69
|
+
# @return [String] a Markdown bullet list
|
|
70
|
+
def environment_block(environment)
|
|
71
|
+
load = environment[:load_average]
|
|
72
|
+
lines = [
|
|
73
|
+
"- CPU: #{environment[:cpu]} (#{environment[:cores]} cores), #{environment[:memory_gib]} GiB",
|
|
74
|
+
"- OS: #{environment[:os]}",
|
|
75
|
+
"- Ruby: #{environment[:ruby]} (YJIT #{environment[:yjit] ? 'on' : 'off'})",
|
|
76
|
+
"- Rust: #{environment[:rustc]}; #{environment[:cargo]}",
|
|
77
|
+
"- Hecks: #{environment[:hecks]} at #{environment[:commit]}",
|
|
78
|
+
"- Load average (1 min) before and after: #{load[:before]} and #{load[:after]}"
|
|
79
|
+
]
|
|
80
|
+
lines << "- Postgres: #{environment[:postgres]}" if environment[:postgres]
|
|
81
|
+
lines.join("\n")
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Renders how hard the run pushed.
|
|
85
|
+
#
|
|
86
|
+
# @param config [Hash] the `:config` of `Suite.call`
|
|
87
|
+
# @return [String] one sentence with the warmup, iteration and run counts
|
|
88
|
+
def configuration_block(config)
|
|
89
|
+
"Warmup #{config[:warmup]} cycles, then #{config[:iterations]} timed cycles per run, " \
|
|
90
|
+
"median of #{config[:runs]} run(s), domains: #{config[:domains].join(', ')}."
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Renders the Rust round-trip floor, if a Rust target ran.
|
|
94
|
+
#
|
|
95
|
+
# @param results [Array<Hash>] the `:results` of `Suite.call`
|
|
96
|
+
# @return [String, nil] a sentence naming each domain's floor, or nil without a Rust result
|
|
97
|
+
def floor_block(results)
|
|
98
|
+
floors = results.select { |entry| entry[:target] == "rust" }
|
|
99
|
+
.map { |entry| "#{entry[:domain]} #{number(entry[:median][:roundtrip_floor_p50_us], 1)} us" }
|
|
100
|
+
return nil if floors.empty?
|
|
101
|
+
|
|
102
|
+
"Rust pipe round-trip floor (p50, a step refused at once): #{floors.join(', ')}."
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Renders the targets that did not run.
|
|
106
|
+
#
|
|
107
|
+
# @param skipped [Array<Hash>] the `:skipped` of `Suite.call`
|
|
108
|
+
# @return [String, nil] one line per skipped target, or nil when none were skipped
|
|
109
|
+
def skipped_block(skipped)
|
|
110
|
+
return nil if skipped.empty?
|
|
111
|
+
|
|
112
|
+
(["Skipped:"] + skipped.map { |entry| "- #{entry[:target]}: #{entry[:reason]}" }).join("\n")
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
require_relative "run"
|
|
2
|
+
|
|
3
|
+
module Hecks
|
|
4
|
+
module Bench
|
|
5
|
+
# Measures the Ruby runtime, in-process, on one persistence adapter.
|
|
6
|
+
#
|
|
7
|
+
# The domain is booted from a throwaway copy through `Fuzzing::IsolatedBoot`, so a
|
|
8
|
+
# run always starts from an empty store and never touches the example's own data.
|
|
9
|
+
# That is the same isolation `bin/fuzz --adapter` uses. Both Postgres adapters write
|
|
10
|
+
# to a uniquely named schema in the `hecks_bench` database, which is dropped after the
|
|
11
|
+
# run, so a concurrent `bin/fuzz` and its shared `hecks_fuzz` schema are never touched.
|
|
12
|
+
#
|
|
13
|
+
# Only `dispatch_flat` is inside the timer. Boot, setup commands and warmup are not.
|
|
14
|
+
module RubyRunner
|
|
15
|
+
# The adapters this runner can bind, as `IsolatedBoot` names them.
|
|
16
|
+
ADAPTERS = %i[memory sqlite postgres postgres_era].freeze
|
|
17
|
+
|
|
18
|
+
# The adapters that need a server and a scratch schema.
|
|
19
|
+
POSTGRES_ADAPTERS = %i[postgres postgres_era].freeze
|
|
20
|
+
|
|
21
|
+
# The scratch database both Postgres adapters run in.
|
|
22
|
+
DATABASE = "hecks_bench".freeze
|
|
23
|
+
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
# Boots the workload's domain on `adapter` and times its commands.
|
|
27
|
+
#
|
|
28
|
+
# @param workload [Workload] the commands to dispatch
|
|
29
|
+
# @param adapter [Symbol] one of `ADAPTERS`
|
|
30
|
+
# @param warmup [Integer] cycles dispatched and discarded before timing starts
|
|
31
|
+
# @param iterations [Integer] cycles timed
|
|
32
|
+
# @return [Run] the timings of the measured cycles
|
|
33
|
+
# @raise [ArgumentError] if `adapter` is not one of `ADAPTERS`
|
|
34
|
+
# @raise [StandardError] whatever refusal a command raises, warmup included, since a
|
|
35
|
+
# benchmark that swallowed one would be timing something other than its workload
|
|
36
|
+
def call(workload, adapter:, warmup:, iterations:)
|
|
37
|
+
unless ADAPTERS.include?(adapter)
|
|
38
|
+
raise ArgumentError, "unknown adapter #{adapter.inspect} — one of #{ADAPTERS.join(', ')}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
load_dependencies
|
|
42
|
+
schema = "bench_#{Process.pid}_#{rand(1 << 32).to_s(16)}"
|
|
43
|
+
quietly_for_postgres do
|
|
44
|
+
boot(workload, adapter, schema) do |runtime|
|
|
45
|
+
workload.setup.each { |step| dispatch(runtime, step) }
|
|
46
|
+
measure(runtime, workload, warmup: warmup, iterations: iterations)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
ensure
|
|
50
|
+
drop_schema(schema) if schema && POSTGRES_ADAPTERS.include?(adapter)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Boots the workload's domain on `adapter` in an isolated copy.
|
|
54
|
+
#
|
|
55
|
+
# @param workload [Workload] the domain to boot
|
|
56
|
+
# @param adapter [Symbol] one of `ADAPTERS`
|
|
57
|
+
# @param schema [String] the scratch schema name, used only by the Postgres adapters
|
|
58
|
+
# @yield [Hecks::Runtime] the booted domain
|
|
59
|
+
# @return [Object] whatever the block returns
|
|
60
|
+
def boot(workload, adapter, schema)
|
|
61
|
+
Hecks::Fuzzing::IsolatedBoot.call(workload.domain_path, adapter: adapter, database: DATABASE,
|
|
62
|
+
schema: schema, scratch: { database: DATABASE, schema: schema }) do |copy|
|
|
63
|
+
yield Hecks.boot(copy, install_facade: false)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Keeps Postgres's `schema already exists` notices off stderr while a block runs.
|
|
68
|
+
#
|
|
69
|
+
# Every aggregate opens its own connection and each one repeats the notice, which
|
|
70
|
+
# buries the progress lines. The server setting is restored afterwards.
|
|
71
|
+
#
|
|
72
|
+
# @yield the work to run with notices quieted
|
|
73
|
+
# @return [Object] whatever the block returns
|
|
74
|
+
def quietly_for_postgres
|
|
75
|
+
previous = ENV.fetch("PGOPTIONS", nil)
|
|
76
|
+
ENV["PGOPTIONS"] = [previous, "-c client_min_messages=warning"].compact.join(" ")
|
|
77
|
+
yield
|
|
78
|
+
ensure
|
|
79
|
+
ENV["PGOPTIONS"] = previous
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Dispatches warmup cycles untimed, then times the measured ones.
|
|
83
|
+
#
|
|
84
|
+
# @param runtime [Hecks::Runtime] the booted domain
|
|
85
|
+
# @param workload [Workload] the commands to dispatch
|
|
86
|
+
# @param warmup [Integer] cycles to discard
|
|
87
|
+
# @param iterations [Integer] cycles to time
|
|
88
|
+
# @return [Run] the timings of the measured cycles
|
|
89
|
+
def measure(runtime, workload, warmup:, iterations:)
|
|
90
|
+
warmup.times { |n| workload.cycle(n).each { |step| dispatch(runtime, step) } }
|
|
91
|
+
GC.start
|
|
92
|
+
samples = []
|
|
93
|
+
started = now
|
|
94
|
+
iterations.times do |n|
|
|
95
|
+
workload.cycle(warmup + n).each { |step| samples << [step.verb, time { dispatch(runtime, step) }] }
|
|
96
|
+
end
|
|
97
|
+
Run.new(samples: samples, wall_seconds: now - started)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Sends one step to the runtime.
|
|
101
|
+
#
|
|
102
|
+
# @param runtime [Hecks::Runtime] the booted domain
|
|
103
|
+
# @param step [Workload::Step] the command to dispatch
|
|
104
|
+
# @return [Object] whatever `dispatch_flat` answers
|
|
105
|
+
def dispatch(runtime, step)
|
|
106
|
+
runtime.dispatch_flat(step.verb, step.ruby_args)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Reads the monotonic clock.
|
|
110
|
+
#
|
|
111
|
+
# @return [Float] seconds on the monotonic clock
|
|
112
|
+
def now
|
|
113
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Times a block.
|
|
117
|
+
#
|
|
118
|
+
# @yield the work to time
|
|
119
|
+
# @return [Float] seconds the block took
|
|
120
|
+
def time
|
|
121
|
+
started = now
|
|
122
|
+
yield
|
|
123
|
+
now - started
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Loads what booting a domain on every adapter needs.
|
|
127
|
+
#
|
|
128
|
+
# @return [void]
|
|
129
|
+
def load_dependencies
|
|
130
|
+
require "hecks"
|
|
131
|
+
require "hecks/fuzzing"
|
|
132
|
+
require "hecks/ports/persistence/plugins/era"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Removes the schema a Postgres run created, so repeated runs do not accumulate.
|
|
136
|
+
#
|
|
137
|
+
# @param schema [String] the schema name this run used
|
|
138
|
+
# @return [void]
|
|
139
|
+
def drop_schema(schema)
|
|
140
|
+
require "pg"
|
|
141
|
+
connection = PG.connect(dbname: DATABASE, connect_timeout: 2)
|
|
142
|
+
connection.exec("SET client_min_messages = warning")
|
|
143
|
+
connection.exec("DROP SCHEMA IF EXISTS #{connection.quote_ident(schema)} CASCADE")
|
|
144
|
+
rescue LoadError, PG::Error
|
|
145
|
+
nil
|
|
146
|
+
ensure
|
|
147
|
+
connection&.close
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
module Hecks
|
|
2
|
+
module Bench
|
|
3
|
+
# The raw timings of one measured pass over a workload on one runtime.
|
|
4
|
+
#
|
|
5
|
+
# Runners produce these and know nothing about reporting. {#summary} is the only
|
|
6
|
+
# place raw samples become the figures a report prints.
|
|
7
|
+
class Run
|
|
8
|
+
# @return [Array<Array(String, Float)>] each timed command's verb and latency in
|
|
9
|
+
# seconds, in dispatch order
|
|
10
|
+
attr_reader :samples
|
|
11
|
+
|
|
12
|
+
# @return [Float] seconds from the first timed command's start to the last one's end
|
|
13
|
+
attr_reader :wall_seconds
|
|
14
|
+
|
|
15
|
+
# @return [Hash{Symbol => Object}] facts a runner wants reported alongside the timings
|
|
16
|
+
attr_reader :extras
|
|
17
|
+
|
|
18
|
+
# Builds a run.
|
|
19
|
+
#
|
|
20
|
+
# @param samples [Array<Array(String, Float)>] verb and latency seconds per command
|
|
21
|
+
# @param wall_seconds [Float] the wall-clock duration of the whole timed pass
|
|
22
|
+
# @param extras [Hash{Symbol => Object}] runner-specific facts, such as a round-trip floor
|
|
23
|
+
def initialize(samples:, wall_seconds:, extras: {})
|
|
24
|
+
@samples = samples
|
|
25
|
+
@wall_seconds = wall_seconds
|
|
26
|
+
@extras = extras
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Reduces the samples to the figures a report prints.
|
|
30
|
+
#
|
|
31
|
+
# @return [Hash{Symbol => Object}] `:commands`, `:wall_seconds`, `:throughput_per_s`,
|
|
32
|
+
# the `Stats.latency` figures, `:drift`, `:by_verb` (each verb's count, p50 and p99),
|
|
33
|
+
# and any `extras`
|
|
34
|
+
def summary
|
|
35
|
+
seconds = samples.map(&:last)
|
|
36
|
+
{
|
|
37
|
+
commands: samples.size,
|
|
38
|
+
wall_seconds: wall_seconds.round(4),
|
|
39
|
+
throughput_per_s: (samples.size / wall_seconds).round(1),
|
|
40
|
+
**Stats.latency(seconds),
|
|
41
|
+
drift: Stats.drift(seconds),
|
|
42
|
+
by_verb: by_verb
|
|
43
|
+
}.merge(extras)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def by_verb
|
|
49
|
+
samples.group_by(&:first).transform_values do |group|
|
|
50
|
+
Stats.latency(group.map(&:last)).slice(:count, :p50_us, :p99_us)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|