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,205 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rspec/core"
|
|
4
|
+
require "shellwords"
|
|
5
|
+
|
|
6
|
+
module RSpec
|
|
7
|
+
module Hopper
|
|
8
|
+
class Worker
|
|
9
|
+
# Loads the RSpec suite once per process, rejects unsupported options,
|
|
10
|
+
# discovers file units, computes the fingerprint and adopts the build seed.
|
|
11
|
+
# This is the only place that mutates RSpec.configuration.
|
|
12
|
+
class Suite
|
|
13
|
+
RUNNER_OPTIONS = {
|
|
14
|
+
"InitializeProject" => "--init", "PrintVersion" => "--version", "PrintHelp" => "--help",
|
|
15
|
+
"Bisect" => "--bisect", "DRbWithFallback" => "--drb"
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
# Captures `message` notifications while spec files load; that is how
|
|
19
|
+
# `Reporter#notify_non_example_exception` surfaces load errors.
|
|
20
|
+
class LoadErrorCapture
|
|
21
|
+
attr_reader :messages
|
|
22
|
+
|
|
23
|
+
def initialize
|
|
24
|
+
@messages = []
|
|
25
|
+
@active = false
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def activate = @active = true
|
|
29
|
+
def deactivate = @active = false
|
|
30
|
+
|
|
31
|
+
def message(notification)
|
|
32
|
+
@messages << notification.message.to_s if @active
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
attr_reader :rspec_args, :config, :options, :runner, :units, :file_counts, :total_examples, :example_ids,
|
|
37
|
+
:load_errors, :fingerprint, :file_args
|
|
38
|
+
|
|
39
|
+
# @return [Suite] loaded and ready; raises UnsupportedOption or BootError
|
|
40
|
+
def self.load(rspec_args, config:, out: $stdout, err: $stderr)
|
|
41
|
+
new(rspec_args, config: config, out: out, err: err).load
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def initialize(rspec_args, config:, out: $stdout, err: $stderr)
|
|
45
|
+
@rspec_args = Array(rspec_args).map(&:to_s)
|
|
46
|
+
@config = config
|
|
47
|
+
@out = out
|
|
48
|
+
@err = err
|
|
49
|
+
@load_errors = []
|
|
50
|
+
@groups_by_file = {}
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def configuration = RSpec.configuration
|
|
54
|
+
def world = RSpec.world
|
|
55
|
+
|
|
56
|
+
def load
|
|
57
|
+
reject_init_option!
|
|
58
|
+
@options = RSpec::Core::ConfigurationOptions.new(rspec_args)
|
|
59
|
+
reject_runner_option!
|
|
60
|
+
@runner = Runner.new(options, configuration, world)
|
|
61
|
+
runner.configure(@err, @out)
|
|
62
|
+
reject_failed_configuration!
|
|
63
|
+
reject_unsupported_configuration!
|
|
64
|
+
normalize_file_args!
|
|
65
|
+
load_spec_files
|
|
66
|
+
world.announce_filters
|
|
67
|
+
discover_units
|
|
68
|
+
@fingerprint = Fingerprint.compute(configuration: configuration, options: options, example_ids: example_ids,
|
|
69
|
+
file_args: file_args, revision: config.revision)
|
|
70
|
+
self
|
|
71
|
+
rescue InfrastructureError
|
|
72
|
+
raise
|
|
73
|
+
rescue StandardError, ScriptError => e
|
|
74
|
+
raise BootError, "#{e.class}: #{e.message}", e.backtrace
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def unit_ids = units.map(&:id)
|
|
78
|
+
|
|
79
|
+
# Top-level groups whose file is the unit, in configured order.
|
|
80
|
+
def groups_for(unit_id) = @groups_by_file.fetch(unit_id, [])
|
|
81
|
+
|
|
82
|
+
# Selected examples of the unit, including nested groups.
|
|
83
|
+
def examples_for(unit_id) = groups_for(unit_id).flat_map(&:descendant_filtered_examples)
|
|
84
|
+
|
|
85
|
+
# Sets the build seed without changing the global ordering strategy:
|
|
86
|
+
# `Configuration#seed=` switches an unforced global ordering to random,
|
|
87
|
+
# which would silently reorder a suite that runs in defined order.
|
|
88
|
+
def adopt_seed(seed)
|
|
89
|
+
return if seed.nil?
|
|
90
|
+
|
|
91
|
+
registry = configuration.ordering_registry
|
|
92
|
+
strategy = registry.fetch(:global)
|
|
93
|
+
configuration.seed = seed
|
|
94
|
+
registry.register(:global, strategy) unless registry.fetch(:global).equal?(strategy)
|
|
95
|
+
configuration.seed
|
|
96
|
+
end
|
|
97
|
+
alias seed= adopt_seed
|
|
98
|
+
|
|
99
|
+
# Applies the `--format`/`--out` pairs of `args` to the already
|
|
100
|
+
# configured RSpec, for a suite loaded once in a shared-boot parent
|
|
101
|
+
# whose children each need their own formatter output. Other arguments
|
|
102
|
+
# are ignored: the suite already applied them. Pairing follows RSpec's
|
|
103
|
+
# parser: `--out` attaches to the preceding `--format`, or to the
|
|
104
|
+
# default progress formatter when there is none.
|
|
105
|
+
def apply_formatter_args(args)
|
|
106
|
+
_remaining, pairs = CLI::FormatterArgs.split(Array(args).map(&:to_s))
|
|
107
|
+
entries = CLI::FormatterArgs.entries(pairs).map { |formatter, out| out ? [formatter, out] : [formatter] }
|
|
108
|
+
entries.each { |entry| configuration.add_formatter(*entry) }
|
|
109
|
+
entries
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def to_manifest(revision: config.revision)
|
|
113
|
+
Manifest.new(
|
|
114
|
+
total_examples: total_examples, file_counts: file_counts, file_args: file_args,
|
|
115
|
+
fingerprint: fingerprint.value, fingerprint_digests: fingerprint.digests,
|
|
116
|
+
seed: configuration.seed, revision: revision, load_errors: load_errors
|
|
117
|
+
)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
private
|
|
121
|
+
|
|
122
|
+
def reject_init_option!
|
|
123
|
+
env_args = ENV["SPEC_OPTS"] ? Shellwords.split(ENV["SPEC_OPTS"]) : []
|
|
124
|
+
return unless (rspec_args + env_args).include?("--init")
|
|
125
|
+
|
|
126
|
+
raise UnsupportedOption, unsupported("--init")
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def reject_runner_option!
|
|
130
|
+
runner = options.options[:runner]
|
|
131
|
+
return if runner.nil?
|
|
132
|
+
|
|
133
|
+
name = RUNNER_OPTIONS.fetch(runner.class.name.to_s.split("::").last, runner.class.name)
|
|
134
|
+
raise UnsupportedOption, unsupported(name)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# A `--require` that fails is reported by RSpec while the options are
|
|
138
|
+
# applied, before spec files load; every worker shares the arguments,
|
|
139
|
+
# so it is a boot failure rather than a spec-file load error.
|
|
140
|
+
def reject_failed_configuration!
|
|
141
|
+
return unless world.wants_to_quit || world.rspec_is_quitting
|
|
142
|
+
|
|
143
|
+
raise BootError, "RSpec reported an error while applying its options (for example a --require that " \
|
|
144
|
+
"could not be loaded); see the RSpec output"
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def reject_unsupported_configuration!
|
|
148
|
+
raise UnsupportedOption, unsupported("--bisect") if options.options[:bisect]
|
|
149
|
+
raise UnsupportedOption, unsupported("--drb") if options.options[:drb]
|
|
150
|
+
raise UnsupportedOption, unsupported("--only-failures / --next-failure") if configuration.only_failures?
|
|
151
|
+
raise UnsupportedOption, unsupported("--fail-fast") if configuration.fail_fast
|
|
152
|
+
raise UnsupportedOption, unsupported("--dry-run") if configuration.dry_run?
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def unsupported(name)
|
|
156
|
+
"RSpec option #{name} is not supported by rspec-hopper: every selected unit must reach a final state"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# RSpec only falls back to `default_path` when `$0` is `rspec`; the
|
|
160
|
+
# worker applies the same default so `rspec-hopper work` with no file
|
|
161
|
+
# arguments runs the spec directory like `rspec` would.
|
|
162
|
+
def normalize_file_args!
|
|
163
|
+
files = Array(options.options[:files_or_directories_to_run]).map(&:to_s)
|
|
164
|
+
files = [configuration.default_path.to_s] if files.empty? && configuration.default_path
|
|
165
|
+
configuration.files_or_directories_to_run = files
|
|
166
|
+
@file_args = files.freeze
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def load_spec_files
|
|
170
|
+
capture = LoadErrorCapture.new
|
|
171
|
+
configuration.reporter.register_listener(capture, :message)
|
|
172
|
+
capture.activate
|
|
173
|
+
begin
|
|
174
|
+
configuration.load_spec_files unless world.wants_to_quit
|
|
175
|
+
rescue SystemExit => e
|
|
176
|
+
capture.messages << "#{e.class} raised while loading spec files" if capture.messages.empty?
|
|
177
|
+
ensure
|
|
178
|
+
capture.deactivate
|
|
179
|
+
end
|
|
180
|
+
return unless world.wants_to_quit || world.rspec_is_quitting
|
|
181
|
+
|
|
182
|
+
@load_errors = capture.messages.dup.freeze
|
|
183
|
+
@load_errors = ["RSpec reported a failure while loading spec files"] if @load_errors.empty?
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def discover_units
|
|
187
|
+
world.ordered_example_groups.each do |group|
|
|
188
|
+
next if group.descendant_filtered_examples.empty?
|
|
189
|
+
|
|
190
|
+
(@groups_by_file[group.metadata[:file_path]] ||= []) << group
|
|
191
|
+
end
|
|
192
|
+
@file_counts = @groups_by_file.transform_values do |groups|
|
|
193
|
+
groups.sum do |g|
|
|
194
|
+
g.descendant_filtered_examples.size
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
.freeze
|
|
198
|
+
@units = @file_counts.keys.map { |path| Unit.file(path) }.freeze
|
|
199
|
+
@total_examples = @file_counts.values.sum
|
|
200
|
+
@example_ids = @groups_by_file.values.flatten.flat_map(&:descendant_filtered_examples).map(&:id).sort.freeze
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "worker/runner"
|
|
5
|
+
|
|
6
|
+
module RSpec
|
|
7
|
+
module Hopper
|
|
8
|
+
# The RSpec adapter: loads the suite, joins or initializes the build, pulls
|
|
9
|
+
# units, runs each through `ExampleGroup.run`, decides requeue from the
|
|
10
|
+
# execution results and forwards final attempts to the formatters.
|
|
11
|
+
class Worker
|
|
12
|
+
POLL_INTERVAL = 0.5
|
|
13
|
+
|
|
14
|
+
Outcome = Data.define(:duration_ms, :escaped, :stale)
|
|
15
|
+
|
|
16
|
+
# Internal: unwinds to `run` with an exit code after the error was recorded and printed.
|
|
17
|
+
class Abort < StandardError
|
|
18
|
+
attr_reader :code
|
|
19
|
+
|
|
20
|
+
def initialize(code)
|
|
21
|
+
@code = code
|
|
22
|
+
super("worker exiting with #{code}")
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Matches redis-rb connection failures without requiring the redis gem.
|
|
27
|
+
REDIS_ERROR = Module.new do
|
|
28
|
+
def self.===(error)
|
|
29
|
+
error.class.ancestors.any? { |a| a.name == "Redis::BaseConnectionError" }
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
attr_reader :config, :suite, :queue
|
|
34
|
+
|
|
35
|
+
# @param queue_factory [#call] returns the Queue; called after the suite boots
|
|
36
|
+
# @param suite [Suite, nil] a pre-loaded suite (shared boot mode); the
|
|
37
|
+
# `--format`/`--out` pairs in `config.rspec_args` are applied to it
|
|
38
|
+
# @param clock [#call] monotonic seconds
|
|
39
|
+
# @param sleeper [#call] sleeps for the given seconds
|
|
40
|
+
# @param aborter [#call] receives an exit code; defaults to `exit!`
|
|
41
|
+
# @param ppid [#call] the parent pid, checked between units when supervised
|
|
42
|
+
def initialize(config:, queue_factory:, suite: nil, out: $stdout, err: $stderr, clock: nil, sleeper: nil,
|
|
43
|
+
aborter: nil, ppid: nil)
|
|
44
|
+
@config = config
|
|
45
|
+
@queue_factory = queue_factory
|
|
46
|
+
@suite = suite
|
|
47
|
+
@out = out
|
|
48
|
+
@err = err
|
|
49
|
+
@clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
50
|
+
@sleeper = sleeper || ->(seconds) { sleep(seconds) }
|
|
51
|
+
@aborter = aborter
|
|
52
|
+
@ppid = ppid || -> { Process.ppid }
|
|
53
|
+
@parent_pid = @ppid.call
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def worker_id = config.worker_id
|
|
57
|
+
def build_id = config.build_id
|
|
58
|
+
|
|
59
|
+
# @return [Integer] exit code: 0 on build completion, 2 on infrastructure failure
|
|
60
|
+
def run
|
|
61
|
+
phase(:boot) do
|
|
62
|
+
if @suite
|
|
63
|
+
@suite.apply_formatter_args(config.rspec_args)
|
|
64
|
+
else
|
|
65
|
+
@suite = Suite.load(config.rspec_args, config: config, out: @out, err: @err)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
phase(:redis) { @queue = @queue_factory.call }
|
|
69
|
+
manifest = phase(:init) { join_build }
|
|
70
|
+
phase(:init) do
|
|
71
|
+
check_fingerprint(manifest)
|
|
72
|
+
suite.adopt_seed(manifest.seed)
|
|
73
|
+
end
|
|
74
|
+
suite.runner.run_specs(manifest.total_examples) { |reporter| work_loop(reporter) }
|
|
75
|
+
ExitCode::OK
|
|
76
|
+
rescue Abort => e
|
|
77
|
+
e.code
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
# --- initialization ---------------------------------------------------
|
|
83
|
+
|
|
84
|
+
def join_build
|
|
85
|
+
deadline = @clock.call + config.init_timeout
|
|
86
|
+
loop do
|
|
87
|
+
status = queue.status
|
|
88
|
+
return joined if status.ready?
|
|
89
|
+
raise InitFailed.new("build #{build_id} failed to initialize", load_errors: recorded_load_errors(status)) if
|
|
90
|
+
status.init_failed?
|
|
91
|
+
raise PreviouslyInitialized, previously_initialized_message if status.tombstone && !status.present?
|
|
92
|
+
|
|
93
|
+
token = queue.acquire_leader(worker_id)
|
|
94
|
+
if token
|
|
95
|
+
manifest = publish(token)
|
|
96
|
+
return manifest if manifest
|
|
97
|
+
else
|
|
98
|
+
raise BuildNeverInitialized, "build never initialized (waited #{config.init_timeout}s)" if
|
|
99
|
+
@clock.call >= deadline
|
|
100
|
+
|
|
101
|
+
@sleeper.call(POLL_INTERVAL)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def joined
|
|
107
|
+
manifest = queue.manifest
|
|
108
|
+
raise BuildStateMissing, "build #{build_id} state is missing" if manifest.nil?
|
|
109
|
+
|
|
110
|
+
say "joined build #{build_id} (#{manifest.total_units} units, #{manifest.total_examples} examples)"
|
|
111
|
+
manifest
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def publish(token)
|
|
115
|
+
manifest = suite.to_manifest
|
|
116
|
+
if suite.load_errors.any?
|
|
117
|
+
queue.fail_initialization(token: token, manifest: manifest)
|
|
118
|
+
raise InitFailed.new("build #{build_id} failed to initialize", load_errors: suite.load_errors)
|
|
119
|
+
end
|
|
120
|
+
queue.initialize_build(token: token, manifest: manifest, unit_ids: suite.unit_ids)
|
|
121
|
+
say "initialized build #{build_id} (#{manifest.total_units} units, #{manifest.total_examples} examples)"
|
|
122
|
+
queue.manifest
|
|
123
|
+
rescue LeaseLost, AlreadyInitialized
|
|
124
|
+
nil
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def recorded_load_errors(status)
|
|
128
|
+
JSON.parse(status.meta&.fetch("load_errors", nil) || "[]")
|
|
129
|
+
rescue JSON::ParserError
|
|
130
|
+
[]
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def previously_initialized_message
|
|
134
|
+
"build #{build_id} was previously initialized; its state is gone. Choose a new build id."
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def check_fingerprint(manifest)
|
|
138
|
+
return if manifest.fingerprint.nil? || manifest.fingerprint == suite.fingerprint.value
|
|
139
|
+
|
|
140
|
+
raise FingerprintMismatch,
|
|
141
|
+
Fingerprint::Mismatch.explain(suite.fingerprint, manifest.fingerprint, manifest.fingerprint_digests)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# --- the loop -----------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
def work_loop(reporter)
|
|
147
|
+
loop do
|
|
148
|
+
phase(:execution) do
|
|
149
|
+
raise InfrastructureError, "RSpec wants to quit (a before(:suite) hook failed?)" if world.wants_to_quit
|
|
150
|
+
end
|
|
151
|
+
reservation = phase(:reserve) { queue.reclaim_lost(worker_id) || queue.reserve(worker_id) }
|
|
152
|
+
if reservation
|
|
153
|
+
execute(reservation, reporter)
|
|
154
|
+
else
|
|
155
|
+
phase(:reserve) { queue.touch_liveness(worker_id) }
|
|
156
|
+
end
|
|
157
|
+
break if phase(:reserve) { queue.complete? }
|
|
158
|
+
|
|
159
|
+
check_parent!
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def check_parent!
|
|
164
|
+
return unless config.supervised
|
|
165
|
+
return if @ppid.call == @parent_pid
|
|
166
|
+
|
|
167
|
+
alert "parent gone; exiting"
|
|
168
|
+
raise Abort, ExitCode::INFRASTRUCTURE
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def execute(reservation, reporter)
|
|
172
|
+
unit_id = reservation.unit_id
|
|
173
|
+
groups = suite.groups_for(unit_id)
|
|
174
|
+
examples = suite.examples_for(unit_id)
|
|
175
|
+
buffer = BufferingReporter.new
|
|
176
|
+
outcome = phase(:execution, unit_id: unit_id) do
|
|
177
|
+
raise InfrastructureError, "unit #{unit_id} is not part of this worker's suite" if groups.empty?
|
|
178
|
+
|
|
179
|
+
run_groups(groups, buffer, reservation)
|
|
180
|
+
end
|
|
181
|
+
decision = RequeuePolicy.decide(examples, escaped: outcome.escaped)
|
|
182
|
+
phase(:execution, unit_id: unit_id) do
|
|
183
|
+
if decision.unexecuted.any? && outcome.escaped.nil?
|
|
184
|
+
raise InfrastructureError, "#{decision.unexecuted.size} example(s) of #{unit_id} did not run"
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
settle(reservation, decision, buffer, outcome.duration_ms, reporter)
|
|
188
|
+
end
|
|
189
|
+
return unless outcome.escaped
|
|
190
|
+
|
|
191
|
+
alert "#{outcome.escaped.class} escaped an example in #{unit_id}; the unit was finalized as failed"
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def run_groups(groups, buffer, reservation)
|
|
195
|
+
ExampleReset.reset(groups)
|
|
196
|
+
heartbeat = Heartbeat.new(queue: queue, reservation: reservation, config: config, err: @err, clock: @clock,
|
|
197
|
+
aborter: @aborter, worker_id: worker_id)
|
|
198
|
+
started = @clock.call
|
|
199
|
+
escaped = nil
|
|
200
|
+
heartbeat.start
|
|
201
|
+
begin
|
|
202
|
+
groups.each { |group| group.run(buffer) }
|
|
203
|
+
rescue *RequeuePolicy::NON_REQUEUEABLE => e
|
|
204
|
+
escaped = e
|
|
205
|
+
ensure
|
|
206
|
+
heartbeat.stop
|
|
207
|
+
end
|
|
208
|
+
Outcome.new(duration_ms: ((@clock.call - started) * 1000).round, escaped: escaped, stale: heartbeat.stale?)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def settle(reservation, decision, buffer, duration_ms, reporter)
|
|
212
|
+
unit_id = reservation.unit_id
|
|
213
|
+
operation = decision.requeue_candidate? ? :requeue : :finalize
|
|
214
|
+
begin
|
|
215
|
+
return if requeued?(reservation, decision, duration_ms)
|
|
216
|
+
rescue StaleReservation
|
|
217
|
+
buffer.discard
|
|
218
|
+
queue.record_stale_rejected(reservation, operation: operation)
|
|
219
|
+
say "reservation for #{unit_id} is stale; result discarded"
|
|
220
|
+
return
|
|
221
|
+
end
|
|
222
|
+
phase(:formatter, unit_id: unit_id) { buffer.replay(reporter) }
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Finalizes or requeues; true when the unit was requeued (buffer dropped).
|
|
226
|
+
def requeued?(reservation, decision, duration_ms)
|
|
227
|
+
unit_id = reservation.unit_id
|
|
228
|
+
if decision.passed?
|
|
229
|
+
queue.finalize(reservation, outcome: :passed, duration_ms: duration_ms)
|
|
230
|
+
elsif decision.final_failure?
|
|
231
|
+
queue.finalize(reservation, outcome: :failed, duration_ms: duration_ms, reason: "test_failure",
|
|
232
|
+
errors: decision.errors)
|
|
233
|
+
else
|
|
234
|
+
result = queue.requeue(reservation, duration_ms: duration_ms, failure_summary: decision.summary,
|
|
235
|
+
errors: decision.errors)
|
|
236
|
+
if result.requeued?
|
|
237
|
+
say "Retrying #{unit_id} (retry #{result.retry_index} of #{config.max_requeues}; " \
|
|
238
|
+
"next attempt #{result.retry_index + 1}): #{decision.summary}"
|
|
239
|
+
return true
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
false
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# --- error handling -----------------------------------------------------
|
|
246
|
+
|
|
247
|
+
# Runs a block for a phase; any failure is recorded, printed and turned
|
|
248
|
+
# into an Abort with the infrastructure exit code.
|
|
249
|
+
def phase(name, unit_id: nil)
|
|
250
|
+
yield
|
|
251
|
+
rescue Abort, StaleReservation, *RequeuePolicy::NON_REQUEUEABLE
|
|
252
|
+
raise
|
|
253
|
+
rescue BuildStateMissing => e
|
|
254
|
+
fail_with(e, phase: "redis", unit_id: unit_id, message: "build state for #{build_id} is gone: #{e.message}")
|
|
255
|
+
rescue CorruptBuild => e
|
|
256
|
+
fail_with(e, phase: "redis", unit_id: unit_id, message: "build #{build_id} is corrupt: #{e.message}")
|
|
257
|
+
rescue REDIS_ERROR => e
|
|
258
|
+
error = RedisUnreachable.new("Redis unreachable: #{e.class}: #{e.message}")
|
|
259
|
+
error.set_backtrace(e.backtrace)
|
|
260
|
+
fail_with(error, phase: "redis", unit_id: unit_id, record: false)
|
|
261
|
+
rescue InitFailed => e
|
|
262
|
+
fail_with(e, phase: name.to_s, unit_id: unit_id, message: init_failed_message(e))
|
|
263
|
+
rescue InfrastructureError => e
|
|
264
|
+
fail_with(e, phase: name.to_s, unit_id: unit_id)
|
|
265
|
+
rescue StandardError => e
|
|
266
|
+
fail_with(e, phase: name.to_s, unit_id: unit_id,
|
|
267
|
+
message: "#{e.class}: #{e.message}\n #{Array(e.backtrace).first(5).join("\n ")}")
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def fail_with(error, phase:, unit_id: nil, message: error.message, record: true)
|
|
271
|
+
alert message
|
|
272
|
+
record_worker_error(error, phase: phase, unit_id: unit_id) if record
|
|
273
|
+
raise Abort, ExitCode::INFRASTRUCTURE
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def record_worker_error(error, phase:, unit_id:)
|
|
277
|
+
return if queue.nil?
|
|
278
|
+
|
|
279
|
+
queue.record_worker_error(worker_id: worker_id, phase: phase, error: error, unit_id: unit_id)
|
|
280
|
+
rescue StandardError => e
|
|
281
|
+
alert "could not record worker error: #{e.class}: #{e.message}"
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def init_failed_message(error)
|
|
285
|
+
lines = ["#{error.message}; spec files could not be loaded:"]
|
|
286
|
+
lines.concat(error.load_errors.map(&:to_s))
|
|
287
|
+
lines.join("\n")
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# --- output -------------------------------------------------------------
|
|
291
|
+
|
|
292
|
+
def world = RSpec.world
|
|
293
|
+
|
|
294
|
+
def say(message) = @out.puts("[hopper #{worker_id}] #{message}")
|
|
295
|
+
|
|
296
|
+
def alert(message) = @err.puts("[hopper #{worker_id}] #{message}")
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
end
|
data/lib/rspec/hopper.rb
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
require_relative "hopper/version"
|
|
6
|
+
require_relative "hopper/errors"
|
|
7
|
+
require_relative "hopper/config"
|
|
8
|
+
require_relative "hopper/unit"
|
|
9
|
+
require_relative "hopper/reservation"
|
|
10
|
+
require_relative "hopper/keys"
|
|
11
|
+
require_relative "hopper/manifest"
|
|
12
|
+
require_relative "hopper/attempt_log"
|
|
13
|
+
require_relative "hopper/queue"
|
|
14
|
+
require_relative "hopper/queue/redis_streams"
|
|
15
|
+
require_relative "hopper/fingerprint"
|
|
16
|
+
require_relative "hopper/example_reset"
|
|
17
|
+
require_relative "hopper/worker/buffering_reporter"
|
|
18
|
+
require_relative "hopper/worker/heartbeat"
|
|
19
|
+
require_relative "hopper/worker/requeue_policy"
|
|
20
|
+
require_relative "hopper/worker/runner"
|
|
21
|
+
require_relative "hopper/worker/suite"
|
|
22
|
+
require_relative "hopper/worker"
|
|
23
|
+
require_relative "hopper/report"
|
|
24
|
+
require_relative "hopper/ci_env"
|
|
25
|
+
require_relative "hopper/supervisor"
|
|
26
|
+
require_relative "hopper/cli/report"
|
|
27
|
+
require_relative "hopper/cli/formatter_args"
|
|
28
|
+
require_relative "hopper/cli/work"
|
|
29
|
+
require_relative "hopper/cli"
|
|
30
|
+
|
|
31
|
+
module RSpec
|
|
32
|
+
# Distributes an RSpec suite across CI workers through Redis Streams.
|
|
33
|
+
module Hopper
|
|
34
|
+
@before_fork_hooks = []
|
|
35
|
+
@after_fork_hooks = []
|
|
36
|
+
|
|
37
|
+
class << self
|
|
38
|
+
# Registers a block to run in the parent, once, before any child is forked
|
|
39
|
+
# in `--boot shared` mode. Close connections opened during boot here.
|
|
40
|
+
def before_fork(&block)
|
|
41
|
+
@before_fork_hooks << block
|
|
42
|
+
block
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Registers a block to run in every child after fork in `--boot shared`
|
|
46
|
+
# mode. The block receives the child's TEST_ENV_NUMBER value ("" or "2".."N").
|
|
47
|
+
def after_fork(&block)
|
|
48
|
+
@after_fork_hooks << block
|
|
49
|
+
block
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# @api private
|
|
53
|
+
attr_reader :before_fork_hooks
|
|
54
|
+
|
|
55
|
+
# @api private
|
|
56
|
+
attr_reader :after_fork_hooks
|
|
57
|
+
|
|
58
|
+
# @api private
|
|
59
|
+
def reset_hooks!
|
|
60
|
+
@before_fork_hooks = []
|
|
61
|
+
@after_fork_hooks = []
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|