rspec-hopper 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/CHANGELOG.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +537 -0
- data/Rakefile +10 -0
- data/docs/DESIGN.md +386 -0
- data/exe/rspec-hopper +6 -0
- data/lib/rspec/hopper/attempt_log.rb +133 -0
- data/lib/rspec/hopper/ci_env.rb +87 -0
- data/lib/rspec/hopper/cli/formatter_args.rb +129 -0
- data/lib/rspec/hopper/cli/report.rb +117 -0
- data/lib/rspec/hopper/cli/work/parser.rb +166 -0
- data/lib/rspec/hopper/cli/work.rb +59 -0
- data/lib/rspec/hopper/cli.rb +61 -0
- data/lib/rspec/hopper/config.rb +48 -0
- data/lib/rspec/hopper/errors.rb +88 -0
- data/lib/rspec/hopper/example_reset.rb +41 -0
- data/lib/rspec/hopper/fingerprint.rb +185 -0
- data/lib/rspec/hopper/keys.rb +38 -0
- data/lib/rspec/hopper/manifest.rb +113 -0
- data/lib/rspec/hopper/queue/redis_streams/lua/init.lua +94 -0
- data/lib/rspec/hopper/queue/redis_streams/lua/transition.lua +476 -0
- data/lib/rspec/hopper/queue/redis_streams.rb +307 -0
- data/lib/rspec/hopper/queue.rb +24 -0
- data/lib/rspec/hopper/report.rb +286 -0
- data/lib/rspec/hopper/reservation.rb +18 -0
- data/lib/rspec/hopper/supervisor.rb +196 -0
- data/lib/rspec/hopper/unit.rb +15 -0
- data/lib/rspec/hopper/version.rb +7 -0
- data/lib/rspec/hopper/worker/buffering_reporter.rb +51 -0
- data/lib/rspec/hopper/worker/heartbeat.rb +178 -0
- data/lib/rspec/hopper/worker/requeue_policy.rb +86 -0
- data/lib/rspec/hopper/worker/runner.rb +28 -0
- data/lib/rspec/hopper/worker/suite.rb +205 -0
- data/lib/rspec/hopper/worker.rb +299 -0
- data/lib/rspec/hopper.rb +65 -0
- metadata +137 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpec
|
|
4
|
+
module Hopper
|
|
5
|
+
module CLI
|
|
6
|
+
# Formatter output must be per child process under `--processes N`. The
|
|
7
|
+
# supervisor strips every `--format`/`--out` option from the RSpec
|
|
8
|
+
# arguments in the parent and re-applies them in each child: `%{n}` in an
|
|
9
|
+
# `--out` path becomes that child's TEST_ENV_NUMBER, and a formatter
|
|
10
|
+
# without an `--out` of its own is given a file under {DEFAULT_DIR}.
|
|
11
|
+
#
|
|
12
|
+
# Children therefore never write to a shared console stream. One build no
|
|
13
|
+
# longer prints one RSpec summary per process (an idle child used to
|
|
14
|
+
# announce "0 examples, 0 failures" for the whole build); the closing word
|
|
15
|
+
# belongs to `rspec-hopper report`, which is the only thing that sees
|
|
16
|
+
# every worker's results.
|
|
17
|
+
module FormatterArgs
|
|
18
|
+
FORMAT_FLAGS = %w[--format -f].freeze
|
|
19
|
+
OUT_FLAGS = %w[--out -o].freeze
|
|
20
|
+
PLACEHOLDER = "%{n}" # rubocop:disable Style/FormatStringToken
|
|
21
|
+
DEFAULT_FORMAT = "progress"
|
|
22
|
+
DEFAULT_DIR = "tmp/rspec-hopper"
|
|
23
|
+
# Extensions for the formatters that write a recognised file format;
|
|
24
|
+
# everything else (progress, documentation, a custom class) gets .txt.
|
|
25
|
+
EXTENSIONS = { "json" => "json", "j" => "json", "html" => "html", "h" => "html", "junit" => "xml" }.freeze
|
|
26
|
+
DEFAULT_EXTENSION = "txt"
|
|
27
|
+
|
|
28
|
+
module_function
|
|
29
|
+
|
|
30
|
+
# Splits the formatter options out of `rspec_args`.
|
|
31
|
+
#
|
|
32
|
+
# @return [Array(Array<String>, Array<Array(String, String)>)] the
|
|
33
|
+
# remaining arguments in original order, and the formatter pairs, each
|
|
34
|
+
# normalized to `["--format", value]` or `["--out", value]`, in the
|
|
35
|
+
# order they appeared. Arguments after `--` are never touched.
|
|
36
|
+
def split(rspec_args)
|
|
37
|
+
remaining = []
|
|
38
|
+
pairs = []
|
|
39
|
+
args = rspec_args.dup
|
|
40
|
+
until args.empty?
|
|
41
|
+
arg = args.shift
|
|
42
|
+
if arg == "--"
|
|
43
|
+
remaining.push(arg, *args)
|
|
44
|
+
break
|
|
45
|
+
end
|
|
46
|
+
flag, value, needs_next = recognise(arg)
|
|
47
|
+
if flag.nil? || (needs_next && args.empty?) # unknown, or a dangling flag RSpec should report
|
|
48
|
+
remaining << arg
|
|
49
|
+
else
|
|
50
|
+
pairs << [flag, needs_next ? args.shift : value]
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
[remaining, pairs]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Groups pairs the way RSpec's own parser does: an `--out` attaches to
|
|
57
|
+
# the preceding `--format`, or to the default progress formatter when
|
|
58
|
+
# there is none.
|
|
59
|
+
#
|
|
60
|
+
# @return [Array<Array(String, String, nil)>] `[formatter, out or nil]`
|
|
61
|
+
def entries(pairs)
|
|
62
|
+
pairs.each_with_object([]) do |(flag, value), list|
|
|
63
|
+
if flag == "--format"
|
|
64
|
+
list << [value, nil]
|
|
65
|
+
else
|
|
66
|
+
list << [DEFAULT_FORMAT, nil] if list.empty?
|
|
67
|
+
list[-1] = [list[-1][0], value]
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Whether any child formatter would be given a generated output path.
|
|
73
|
+
def defaults_needed?(pairs)
|
|
74
|
+
list = entries(pairs)
|
|
75
|
+
list.empty? || list.any? { |(_formatter, out)| out.nil? }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The flat argument list to prepend to a child's RSpec arguments. Every
|
|
79
|
+
# formatter comes back with an explicit `--out`, so no child writes to
|
|
80
|
+
# the console.
|
|
81
|
+
#
|
|
82
|
+
# @param pairs [Array<Array(String, String)>] from {split}
|
|
83
|
+
# @param env_number [String] the child's TEST_ENV_NUMBER ("" or "2"..)
|
|
84
|
+
# @param label [String, nil] the child's worker id, used in generated names
|
|
85
|
+
# @param dir [String] where generated output files go
|
|
86
|
+
def for_child(pairs, env_number, label: nil, dir: DEFAULT_DIR)
|
|
87
|
+
list = entries(pairs)
|
|
88
|
+
list = [[DEFAULT_FORMAT, nil]] if list.empty?
|
|
89
|
+
seen = Hash.new(0)
|
|
90
|
+
list.flat_map do |formatter, out|
|
|
91
|
+
path = out ? out.gsub(PLACEHOLDER, env_number) : generated_path(dir, label, formatter, env_number, seen)
|
|
92
|
+
["--format", formatter, "--out", path]
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# `tmp/rspec-hopper/w-2-json.json`, numbered when one child names the
|
|
97
|
+
# same formatter twice.
|
|
98
|
+
def generated_path(dir, label, formatter, env_number, seen)
|
|
99
|
+
slug = slugify(formatter)
|
|
100
|
+
occurrence = (seen[slug] += 1)
|
|
101
|
+
suffix = occurrence > 1 ? "-#{occurrence}" : ""
|
|
102
|
+
base = label.to_s.empty? ? "worker#{env_number}" : label.to_s
|
|
103
|
+
File.join(dir, "#{slugify(base)}-#{slug}#{suffix}.#{EXTENSIONS.fetch(slug, DEFAULT_EXTENSION)}")
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def slugify(value)
|
|
107
|
+
slug = value.to_s.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-+|-+\z/, "")
|
|
108
|
+
slug.empty? ? DEFAULT_FORMAT : slug
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# @return [Array(String, String, Boolean), nil] normalized flag, inline
|
|
112
|
+
# value (or nil), and whether the value is the next argument.
|
|
113
|
+
def recognise(arg)
|
|
114
|
+
FORMAT_FLAGS.each { |f| (m = match(f, arg)) and return ["--format", *m] }
|
|
115
|
+
OUT_FLAGS.each { |f| (m = match(f, arg)) and return ["--out", *m] }
|
|
116
|
+
nil
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def match(flag, arg)
|
|
120
|
+
return [nil, true] if arg == flag
|
|
121
|
+
return [arg.delete_prefix("#{flag}="), false] if flag.start_with?("--") && arg.start_with?("#{flag}=")
|
|
122
|
+
return [arg.delete_prefix(flag), false] if !flag.start_with?("--") && arg.start_with?(flag) && arg.size > 2
|
|
123
|
+
|
|
124
|
+
nil
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
require "redis"
|
|
5
|
+
|
|
6
|
+
module RSpec
|
|
7
|
+
module Hopper
|
|
8
|
+
module CLI
|
|
9
|
+
# `rspec-hopper report`: parses flags into a ReportConfig and runs Report.
|
|
10
|
+
class Report
|
|
11
|
+
# Raised by `parse` for -h/--help; `run` prints usage and returns 0.
|
|
12
|
+
class HelpRequested < StandardError; end
|
|
13
|
+
|
|
14
|
+
BANNER = "Usage: rspec-hopper report --build ID --redis URL [options]"
|
|
15
|
+
|
|
16
|
+
class << self
|
|
17
|
+
def usage = build_parser({}).to_s
|
|
18
|
+
|
|
19
|
+
# Returns a frozen ReportConfig. Build id falls back to HOPPER_BUILD_ID;
|
|
20
|
+
# the Redis URL to HOPPER_REDIS_URL then REDIS_URL.
|
|
21
|
+
def parse(argv, env: ENV)
|
|
22
|
+
opts = {}
|
|
23
|
+
rest = build_parser(opts).parse(argv)
|
|
24
|
+
raise UsageError, "unexpected argument(s): #{rest.join(" ")}" if rest.any?
|
|
25
|
+
|
|
26
|
+
opts[:build_id] ||= env["HOPPER_BUILD_ID"]
|
|
27
|
+
opts[:redis_url] ||= env["HOPPER_REDIS_URL"] || env["REDIS_URL"]
|
|
28
|
+
validate!(opts)
|
|
29
|
+
ReportConfig.build(**opts)
|
|
30
|
+
rescue OptionParser::ParseError => e
|
|
31
|
+
raise UsageError, e.message
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def run(argv, out: $stdout, err: $stderr, env: ENV)
|
|
35
|
+
config = parse(argv, env: env)
|
|
36
|
+
redis = Redis.new(url: config.redis_url)
|
|
37
|
+
queue = Queue::RedisStreams.new(redis: redis, build_id: config.build_id)
|
|
38
|
+
Hopper::Report.new(config: config, queue: queue).run(out: out)
|
|
39
|
+
rescue HelpRequested
|
|
40
|
+
out.puts usage
|
|
41
|
+
ExitCode::OK
|
|
42
|
+
rescue UsageError => e
|
|
43
|
+
err.puts "rspec-hopper report: #{e.message}"
|
|
44
|
+
err.puts usage
|
|
45
|
+
ExitCode::INFRASTRUCTURE
|
|
46
|
+
rescue InfrastructureError => e
|
|
47
|
+
err.puts "rspec-hopper report: #{e.message}"
|
|
48
|
+
e.exit_code
|
|
49
|
+
ensure
|
|
50
|
+
redis&.close
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def validate!(opts)
|
|
56
|
+
raise UsageError, "--build ID is required (or set HOPPER_BUILD_ID)" unless opts[:build_id]
|
|
57
|
+
raise UsageError, "--redis URL is required (or set HOPPER_REDIS_URL / REDIS_URL)" unless opts[:redis_url]
|
|
58
|
+
|
|
59
|
+
if opts[:allow_empty] && opts.fetch(:min_examples, 0).positive?
|
|
60
|
+
raise UsageError, "--allow-empty cannot be combined with a positive --min-examples"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
%i[timeout init_timeout inactive_timeout min_examples].each do |key|
|
|
64
|
+
raise UsageError, "--#{key.to_s.tr("_", "-")} must not be negative" if opts[key]&.negative?
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def build_parser(opts)
|
|
69
|
+
OptionParser.new(BANNER, 28) do |parser|
|
|
70
|
+
parser.separator ""
|
|
71
|
+
parser.separator "Waits for the build to initialize and complete, then prints the verdict."
|
|
72
|
+
parser.separator ""
|
|
73
|
+
connection_options(parser, opts)
|
|
74
|
+
wait_options(parser, opts)
|
|
75
|
+
output_options(parser, opts)
|
|
76
|
+
verdict_options(parser, opts)
|
|
77
|
+
parser.on_tail("-h", "--help", "show this help") { raise HelpRequested }
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def connection_options(parser, opts)
|
|
82
|
+
parser.on("--build ID", "build id (default: $HOPPER_BUILD_ID)") { |v| opts[:build_id] = v }
|
|
83
|
+
parser.on("--redis URL", "Redis URL (default: $HOPPER_REDIS_URL, then $REDIS_URL)") do |v|
|
|
84
|
+
opts[:redis_url] = v
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def wait_options(parser, opts)
|
|
89
|
+
parser.on("--timeout S", Float, "give up after S seconds (default 1080)") { |v| opts[:timeout] = v }
|
|
90
|
+
parser.on("--init-timeout S", Float, "wait S seconds for initialization (default 300)") do |v|
|
|
91
|
+
opts[:init_timeout] = v
|
|
92
|
+
end
|
|
93
|
+
parser.on("--inactive-timeout S", Float,
|
|
94
|
+
"give up after S seconds without worker activity (default 300)") do |v|
|
|
95
|
+
opts[:inactive_timeout] = v
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def output_options(parser, opts)
|
|
100
|
+
parser.on("--summary-out PATH", "write the JSON summary to PATH") { |v| opts[:summary_out] = v }
|
|
101
|
+
parser.on("--failed-out PATH", "write failed and never-finalized unit ids to PATH") do |v|
|
|
102
|
+
opts[:failed_out] = v
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def verdict_options(parser, opts)
|
|
107
|
+
parser.on("--fail-on-empty", "zero selected examples is a failure (default)") { opts[:allow_empty] = false }
|
|
108
|
+
parser.on("--allow-empty", "zero selected examples may pass") { opts[:allow_empty] = true }
|
|
109
|
+
parser.on("--min-examples N", Integer, "fail unless at least N examples were selected") do |v|
|
|
110
|
+
opts[:min_examples] = v
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
module RSpec
|
|
6
|
+
module Hopper
|
|
7
|
+
module CLI
|
|
8
|
+
module Work
|
|
9
|
+
# OptionParser for `rspec-hopper work`. Consumes the gem's own flags
|
|
10
|
+
# and passes every other argument, and everything after `--`, to RSpec
|
|
11
|
+
# in the original order.
|
|
12
|
+
class Parser
|
|
13
|
+
BOOT_MODES = { "per-process" => :per_process, "shared" => :shared }.freeze
|
|
14
|
+
POSITIVE = %i[timeout max_unit_duration ttl tombstone_ttl init_timeout].freeze
|
|
15
|
+
NON_NEGATIVE = %i[max_requeues max_reclaims].freeze
|
|
16
|
+
|
|
17
|
+
BANNER = <<~BANNER
|
|
18
|
+
Usage: rspec-hopper work --build ID --worker WID --redis URL [options] [rspec args...] -- [files...]
|
|
19
|
+
|
|
20
|
+
Every argument the gem does not recognise, and everything after `--`, is
|
|
21
|
+
passed to RSpec unchanged. --build, --worker and --redis fall back to
|
|
22
|
+
HOPPER_BUILD_ID, HOPPER_WORKER_ID and HOPPER_REDIS_URL/REDIS_URL, then to
|
|
23
|
+
CI environment variables (CircleCI, Buildkite, GitHub Actions, GitLab).
|
|
24
|
+
BANNER
|
|
25
|
+
|
|
26
|
+
# @return [WorkConfig]
|
|
27
|
+
# @raise [UsageError, HelpRequested]
|
|
28
|
+
def self.parse(argv, env: ENV) = new(env: env).parse(argv)
|
|
29
|
+
|
|
30
|
+
def initialize(env: ENV)
|
|
31
|
+
@env = env
|
|
32
|
+
@opts = {}
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def parse(argv)
|
|
36
|
+
rspec_args = extract(argv)
|
|
37
|
+
resolve_ids
|
|
38
|
+
validate_ids!
|
|
39
|
+
validate_numbers!
|
|
40
|
+
WorkConfig.build(
|
|
41
|
+
**@opts, rspec_args: rspec_args.freeze,
|
|
42
|
+
report_args: ["--build", @opts[:build_id], "--redis", @opts[:redis_url]].freeze
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
# Consumes the gem's flags from argv; returns everything else.
|
|
49
|
+
def extract(argv)
|
|
50
|
+
own, tail = split_on_double_dash(argv)
|
|
51
|
+
rest = []
|
|
52
|
+
parser = option_parser
|
|
53
|
+
begin
|
|
54
|
+
parser.order!(own) { |positional| rest << positional }
|
|
55
|
+
rescue OptionParser::InvalidOption => e
|
|
56
|
+
e.recover(own)
|
|
57
|
+
rest << own.shift
|
|
58
|
+
retry
|
|
59
|
+
rescue OptionParser::ParseError => e
|
|
60
|
+
raise UsageError, e.message
|
|
61
|
+
end
|
|
62
|
+
rest + tail
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def split_on_double_dash(argv)
|
|
66
|
+
index = argv.index("--")
|
|
67
|
+
return [argv.dup, []] if index.nil?
|
|
68
|
+
|
|
69
|
+
[argv[0...index], argv[index..]]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def option_parser
|
|
73
|
+
OptionParser.new do |o|
|
|
74
|
+
o.banner = BANNER
|
|
75
|
+
identity_options(o)
|
|
76
|
+
policy_options(o)
|
|
77
|
+
process_options(o)
|
|
78
|
+
lifetime_options(o)
|
|
79
|
+
o.separator ""
|
|
80
|
+
o.on("-h", "--help", "show this help") { raise HelpRequested, o.help }
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def identity_options(opt)
|
|
85
|
+
opt.separator ""
|
|
86
|
+
opt.separator "Identity:"
|
|
87
|
+
opt.on("--build ID", "build id shared by every worker of one CI run") { |v| @opts[:build_id] = v }
|
|
88
|
+
opt.on("--worker WID", "this worker's id, unique within the build") { |v| @opts[:worker_id] = v }
|
|
89
|
+
opt.on("--redis URL", "Redis URL") { |v| @opts[:redis_url] = v }
|
|
90
|
+
opt.on("--revision SHA", "revision string mixed into the suite fingerprint") { |v| @opts[:revision] = v }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def policy_options(opt)
|
|
94
|
+
opt.separator ""
|
|
95
|
+
opt.separator "Requeue and timeout policy:"
|
|
96
|
+
opt.on("--timeout SECONDS", Numeric, "missed-heartbeat window before a unit is reclaimable (180)") do |v|
|
|
97
|
+
@opts[:timeout] = v
|
|
98
|
+
end
|
|
99
|
+
opt.on("--max-unit-duration SECONDS", Numeric, "abandon a unit running longer than this (900)") do |v|
|
|
100
|
+
@opts[:max_unit_duration] = v
|
|
101
|
+
end
|
|
102
|
+
opt.on("--max-requeues N", Integer, "max retries of any one unit (0)") { |v| @opts[:max_requeues] = v }
|
|
103
|
+
opt.on("--requeue-tolerance R", Float, "fraction of units allowed to retry (0)") do |v|
|
|
104
|
+
@opts[:requeue_tolerance] = v
|
|
105
|
+
end
|
|
106
|
+
opt.on("--max-reclaims N", Integer, "reclaims from dead workers before a unit fails (3)") do |v|
|
|
107
|
+
@opts[:max_reclaims] = v
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def process_options(opt)
|
|
112
|
+
opt.separator ""
|
|
113
|
+
opt.separator "Process parallelism:"
|
|
114
|
+
opt.on("--processes N", Integer, "fork N worker processes on this machine (1)") do |v|
|
|
115
|
+
@opts[:processes] = v
|
|
116
|
+
end
|
|
117
|
+
opt.on("--boot MODE", BOOT_MODES.keys, "per-process (default) or shared") do |v|
|
|
118
|
+
@opts[:boot] = BOOT_MODES[v]
|
|
119
|
+
end
|
|
120
|
+
opt.on("--report-on-exit", "parent runs `report` after all children exit") do
|
|
121
|
+
@opts[:report_on_exit] = true
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def lifetime_options(opt)
|
|
126
|
+
opt.separator ""
|
|
127
|
+
opt.separator "Redis lifetimes:"
|
|
128
|
+
opt.on("--ttl SECONDS", Numeric, "inactivity TTL of live build keys (14400)") { |v| @opts[:ttl] = v }
|
|
129
|
+
opt.on("--tombstone-ttl SECONDS", Numeric, "lifetime of the build tombstone (604800)") do |v|
|
|
130
|
+
@opts[:tombstone_ttl] = v
|
|
131
|
+
end
|
|
132
|
+
opt.on("--init-timeout SECONDS", Numeric, "wait this long for build initialization (300)") do |v|
|
|
133
|
+
@opts[:init_timeout] = v
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def resolve_ids
|
|
138
|
+
@opts[:build_id] ||= CIEnv.present(@env["HOPPER_BUILD_ID"]) || CIEnv.build_id(@env)
|
|
139
|
+
@opts[:worker_id] ||= CIEnv.present(@env["HOPPER_WORKER_ID"]) || CIEnv.worker_id(@env) ||
|
|
140
|
+
CIEnv.default_worker_id
|
|
141
|
+
@opts[:redis_url] ||= CIEnv.present(@env["HOPPER_REDIS_URL"]) || CIEnv.present(@env["REDIS_URL"])
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def validate_ids!
|
|
145
|
+
raise UsageError, "--build is required (or set HOPPER_BUILD_ID)" unless @opts[:build_id]
|
|
146
|
+
raise UsageError, "--redis is required (or set HOPPER_REDIS_URL or REDIS_URL)" unless @opts[:redis_url]
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def validate_numbers!
|
|
150
|
+
POSITIVE.each { |key| check(key, "must be positive", &:positive?) }
|
|
151
|
+
NON_NEGATIVE.each { |key| check(key, "must not be negative") { |v| !v.negative? } }
|
|
152
|
+
check(:processes, "must be at least 1") { |v| v >= 1 }
|
|
153
|
+
check(:requeue_tolerance, "must be between 0 and 1") { |v| (0.0..1.0).cover?(v) }
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def check(key, requirement)
|
|
157
|
+
return unless @opts.key?(key)
|
|
158
|
+
return if yield(@opts[key])
|
|
159
|
+
|
|
160
|
+
raise UsageError, "--#{key.to_s.tr("_", "-")} #{requirement}, got #{@opts[key]}"
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "work/parser"
|
|
4
|
+
|
|
5
|
+
module RSpec
|
|
6
|
+
module Hopper
|
|
7
|
+
module CLI
|
|
8
|
+
# `rspec-hopper work`: parses the gem's own flags into a frozen WorkConfig
|
|
9
|
+
# and hands everything else to RSpec, then runs one worker inline or a
|
|
10
|
+
# Supervisor for `--processes N`.
|
|
11
|
+
module Work
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# @return [Integer] exit code
|
|
15
|
+
def run(argv, out: $stdout, err: $stderr, env: ENV, worker_class: nil, supervisor_class: nil)
|
|
16
|
+
config = parse(argv, env: env)
|
|
17
|
+
worker_class ||= Worker
|
|
18
|
+
if config.processes == 1
|
|
19
|
+
worker_class.new(config: config, queue_factory: queue_factory(config), out: out, err: err).run
|
|
20
|
+
else
|
|
21
|
+
(supervisor_class || Supervisor).new(config: config, out: out, err: err, worker_class: worker_class).run
|
|
22
|
+
end
|
|
23
|
+
rescue HelpRequested => e
|
|
24
|
+
out.puts e.text
|
|
25
|
+
ExitCode::OK
|
|
26
|
+
rescue UsageError => e
|
|
27
|
+
err.puts "rspec-hopper work: #{e.message}"
|
|
28
|
+
err.puts "Run `rspec-hopper work --help` for usage."
|
|
29
|
+
ExitCode::INFRASTRUCTURE
|
|
30
|
+
rescue Redis::BaseConnectionError => e
|
|
31
|
+
err.puts "rspec-hopper work: Redis unreachable at #{config&.redis_url}: #{e.message}"
|
|
32
|
+
ExitCode::INFRASTRUCTURE
|
|
33
|
+
rescue InfrastructureError => e
|
|
34
|
+
err.puts "rspec-hopper work: #{e.message}"
|
|
35
|
+
e.exit_code
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @param argv [Array<String>] arguments after the `work` subcommand
|
|
39
|
+
# @param env [#[]] environment for --build/--worker/--redis fallbacks
|
|
40
|
+
# @return [WorkConfig]
|
|
41
|
+
# @raise [UsageError, HelpRequested]
|
|
42
|
+
def parse(argv, env: ENV) = Parser.parse(argv, env: env)
|
|
43
|
+
|
|
44
|
+
# A zero-argument lambda opening Redis on first call, so the suite can
|
|
45
|
+
# boot before any connection exists and children open their own.
|
|
46
|
+
def queue_factory(config)
|
|
47
|
+
lambda do
|
|
48
|
+
Queue::RedisStreams.new(
|
|
49
|
+
redis: Redis.new(url: config.redis_url), build_id: config.build_id,
|
|
50
|
+
ttl: config.ttl, tombstone_ttl: config.tombstone_ttl, timeout: config.timeout,
|
|
51
|
+
max_requeues: config.max_requeues, requeue_tolerance: config.requeue_tolerance,
|
|
52
|
+
max_reclaims: config.max_reclaims
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpec
|
|
4
|
+
module Hopper
|
|
5
|
+
# Command-line entry point: dispatches `work` and `report`.
|
|
6
|
+
module CLI
|
|
7
|
+
# Raised by a subcommand parser when `-h`/`--help` is given; carries the
|
|
8
|
+
# help text so the caller can print it to stdout and exit 0.
|
|
9
|
+
class HelpRequested < StandardError
|
|
10
|
+
attr_reader :text
|
|
11
|
+
|
|
12
|
+
def initialize(text)
|
|
13
|
+
@text = text
|
|
14
|
+
super("help requested")
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
USAGE = <<~USAGE
|
|
19
|
+
Usage: rspec-hopper <command> [options]
|
|
20
|
+
|
|
21
|
+
Commands:
|
|
22
|
+
work run a worker: boot the suite, join the build, consume the queue
|
|
23
|
+
report wait for a build to finish and print the verdict
|
|
24
|
+
|
|
25
|
+
Options:
|
|
26
|
+
-h, --help show this help
|
|
27
|
+
-v, --version print the version
|
|
28
|
+
|
|
29
|
+
Run `rspec-hopper work --help` or `rspec-hopper report --help` for the
|
|
30
|
+
options of each command.
|
|
31
|
+
USAGE
|
|
32
|
+
|
|
33
|
+
module_function
|
|
34
|
+
|
|
35
|
+
# @return [Integer] process exit code
|
|
36
|
+
def run(argv, out: $stdout, err: $stderr)
|
|
37
|
+
command, *rest = argv
|
|
38
|
+
case command
|
|
39
|
+
when "work" then Work.run(rest, out: out, err: err)
|
|
40
|
+
when "report" then Report.run(rest, out: out, err: err)
|
|
41
|
+
when "-v", "--version"
|
|
42
|
+
out.puts "rspec-hopper #{VERSION}"
|
|
43
|
+
ExitCode::OK
|
|
44
|
+
when "-h", "--help", "help"
|
|
45
|
+
out.puts USAGE
|
|
46
|
+
ExitCode::OK
|
|
47
|
+
else
|
|
48
|
+
err.puts(command.nil? ? "rspec-hopper: no command given" : "rspec-hopper: unknown command #{command.inspect}")
|
|
49
|
+
err.puts USAGE
|
|
50
|
+
ExitCode::INFRASTRUCTURE
|
|
51
|
+
end
|
|
52
|
+
rescue HelpRequested => e
|
|
53
|
+
out.puts e.text
|
|
54
|
+
ExitCode::OK
|
|
55
|
+
rescue UsageError => e
|
|
56
|
+
err.puts "rspec-hopper: #{e.message}"
|
|
57
|
+
ExitCode::INFRASTRUCTURE
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpec
|
|
4
|
+
module Hopper
|
|
5
|
+
# Frozen configuration for the `work` subcommand. Passed explicitly to every
|
|
6
|
+
# collaborator; there is no global.
|
|
7
|
+
WorkConfig = Data.define(
|
|
8
|
+
:build_id, :worker_id, :redis_url,
|
|
9
|
+
:timeout, :max_unit_duration, :max_requeues, :requeue_tolerance, :max_reclaims,
|
|
10
|
+
:processes, :boot, :report_on_exit,
|
|
11
|
+
:ttl, :tombstone_ttl, :init_timeout, :revision,
|
|
12
|
+
:rspec_args, :report_args, :supervised
|
|
13
|
+
) do
|
|
14
|
+
def self.build(**attrs)
|
|
15
|
+
new(**Config::WORK_DEFAULTS, **attrs)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def heartbeat_interval
|
|
19
|
+
[timeout / 3.0, 30.0].min
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Frozen configuration for the `report` subcommand.
|
|
24
|
+
ReportConfig = Data.define(
|
|
25
|
+
:build_id, :redis_url, :timeout, :init_timeout, :inactive_timeout,
|
|
26
|
+
:summary_out, :failed_out, :allow_empty, :min_examples
|
|
27
|
+
) do
|
|
28
|
+
def self.build(**attrs)
|
|
29
|
+
new(**Config::REPORT_DEFAULTS, **attrs)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
module Config
|
|
34
|
+
WORK_DEFAULTS = {
|
|
35
|
+
build_id: nil, worker_id: nil, redis_url: nil,
|
|
36
|
+
timeout: 180, max_unit_duration: 900, max_requeues: 0, requeue_tolerance: 0.0, max_reclaims: 3,
|
|
37
|
+
processes: 1, boot: :per_process, report_on_exit: false,
|
|
38
|
+
ttl: 14_400, tombstone_ttl: 604_800, init_timeout: 300, revision: nil,
|
|
39
|
+
rspec_args: [], report_args: [], supervised: false
|
|
40
|
+
}.freeze
|
|
41
|
+
|
|
42
|
+
REPORT_DEFAULTS = {
|
|
43
|
+
build_id: nil, redis_url: nil, timeout: 1080, init_timeout: 300, inactive_timeout: 300,
|
|
44
|
+
summary_out: nil, failed_out: nil, allow_empty: false, min_examples: 0
|
|
45
|
+
}.freeze
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpec
|
|
4
|
+
module Hopper
|
|
5
|
+
# Process exit codes. Precedence between workers is semantic, not numeric:
|
|
6
|
+
# INFRASTRUCTURE beats ABORTED beats OK.
|
|
7
|
+
module ExitCode
|
|
8
|
+
OK = 0
|
|
9
|
+
TEST_FAILURE = 1
|
|
10
|
+
INFRASTRUCTURE = 2
|
|
11
|
+
INCOMPLETE = 3
|
|
12
|
+
ABORTED = 4
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
class Error < StandardError; end
|
|
16
|
+
|
|
17
|
+
# Anything that makes the worker exit 2.
|
|
18
|
+
class InfrastructureError < Error
|
|
19
|
+
def exit_code = ExitCode::INFRASTRUCTURE
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
class BootError < InfrastructureError; end
|
|
23
|
+
class RedisUnreachable < InfrastructureError; end
|
|
24
|
+
class BuildNeverInitialized < InfrastructureError; end
|
|
25
|
+
|
|
26
|
+
class InitFailed < InfrastructureError
|
|
27
|
+
attr_reader :load_errors
|
|
28
|
+
|
|
29
|
+
def initialize(message = "build initialization failed", load_errors: [])
|
|
30
|
+
@load_errors = load_errors
|
|
31
|
+
super(message)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
class PreviouslyInitialized < InfrastructureError; end
|
|
36
|
+
class AlreadyInitialized < InfrastructureError; end
|
|
37
|
+
class LeaseLost < InfrastructureError; end
|
|
38
|
+
class FingerprintMismatch < InfrastructureError; end
|
|
39
|
+
class UnsupportedOption < InfrastructureError; end
|
|
40
|
+
class SharedBootWithoutHook < InfrastructureError; end
|
|
41
|
+
class ForkUnavailable < InfrastructureError; end
|
|
42
|
+
class BuildStateMissing < InfrastructureError; end
|
|
43
|
+
class CorruptBuild < InfrastructureError; end
|
|
44
|
+
class UsageError < InfrastructureError; end
|
|
45
|
+
|
|
46
|
+
# Raised by queue mutations when ownership of the reservation has moved.
|
|
47
|
+
# Not an infrastructure failure: the worker discards its result and moves on.
|
|
48
|
+
class StaleReservation < Error; end
|
|
49
|
+
|
|
50
|
+
# Size-capped JSON error payloads recorded in the attempt log.
|
|
51
|
+
module ErrorPayload
|
|
52
|
+
MESSAGE_BYTES = 4096
|
|
53
|
+
BACKTRACE_LINES = 20
|
|
54
|
+
TOTAL_BYTES = 65_536
|
|
55
|
+
|
|
56
|
+
module_function
|
|
57
|
+
|
|
58
|
+
def from_exception(error, example_id: nil, description: nil)
|
|
59
|
+
{
|
|
60
|
+
"example_id" => example_id,
|
|
61
|
+
"description" => description,
|
|
62
|
+
"class" => error.class.name,
|
|
63
|
+
"message" => truncate(error.message.to_s, MESSAGE_BYTES),
|
|
64
|
+
"backtrace" => (error.backtrace || []).first(BACKTRACE_LINES)
|
|
65
|
+
}
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Drops trailing entries until the JSON fits, appending a marker for how
|
|
69
|
+
# many were dropped.
|
|
70
|
+
def cap(errors)
|
|
71
|
+
kept = errors.dup
|
|
72
|
+
dropped = 0
|
|
73
|
+
while kept.any? && JSON.generate(kept).bytesize > TOTAL_BYTES
|
|
74
|
+
kept.pop
|
|
75
|
+
dropped += 1
|
|
76
|
+
end
|
|
77
|
+
kept << { "truncated" => dropped } if dropped.positive?
|
|
78
|
+
kept
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def truncate(string, bytes)
|
|
82
|
+
return string if string.bytesize <= bytes
|
|
83
|
+
|
|
84
|
+
"#{string.byteslice(0, bytes - 3).scrub("")}..."
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|