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.
Files changed (37) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +10 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +537 -0
  5. data/Rakefile +10 -0
  6. data/docs/DESIGN.md +386 -0
  7. data/exe/rspec-hopper +6 -0
  8. data/lib/rspec/hopper/attempt_log.rb +133 -0
  9. data/lib/rspec/hopper/ci_env.rb +87 -0
  10. data/lib/rspec/hopper/cli/formatter_args.rb +129 -0
  11. data/lib/rspec/hopper/cli/report.rb +117 -0
  12. data/lib/rspec/hopper/cli/work/parser.rb +166 -0
  13. data/lib/rspec/hopper/cli/work.rb +59 -0
  14. data/lib/rspec/hopper/cli.rb +61 -0
  15. data/lib/rspec/hopper/config.rb +48 -0
  16. data/lib/rspec/hopper/errors.rb +88 -0
  17. data/lib/rspec/hopper/example_reset.rb +41 -0
  18. data/lib/rspec/hopper/fingerprint.rb +185 -0
  19. data/lib/rspec/hopper/keys.rb +38 -0
  20. data/lib/rspec/hopper/manifest.rb +113 -0
  21. data/lib/rspec/hopper/queue/redis_streams/lua/init.lua +94 -0
  22. data/lib/rspec/hopper/queue/redis_streams/lua/transition.lua +476 -0
  23. data/lib/rspec/hopper/queue/redis_streams.rb +307 -0
  24. data/lib/rspec/hopper/queue.rb +24 -0
  25. data/lib/rspec/hopper/report.rb +286 -0
  26. data/lib/rspec/hopper/reservation.rb +18 -0
  27. data/lib/rspec/hopper/supervisor.rb +196 -0
  28. data/lib/rspec/hopper/unit.rb +15 -0
  29. data/lib/rspec/hopper/version.rb +7 -0
  30. data/lib/rspec/hopper/worker/buffering_reporter.rb +51 -0
  31. data/lib/rspec/hopper/worker/heartbeat.rb +178 -0
  32. data/lib/rspec/hopper/worker/requeue_policy.rb +86 -0
  33. data/lib/rspec/hopper/worker/runner.rb +28 -0
  34. data/lib/rspec/hopper/worker/suite.rb +205 -0
  35. data/lib/rspec/hopper/worker.rb +299 -0
  36. data/lib/rspec/hopper.rb +65 -0
  37. 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