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,196 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpec
|
|
4
|
+
module Hopper
|
|
5
|
+
# The `--processes N` parent. Forks N children (each a standalone worker
|
|
6
|
+
# with worker id `<worker>.<n>` and its own TEST_ENV_NUMBER), forwards INT
|
|
7
|
+
# and TERM to them, waits for all of them, and exits per the precedence
|
|
8
|
+
# rules: 2 if any child failed on infrastructure, else 4 if any child
|
|
9
|
+
# aborted a hung unit, else 0 -- or, with --report-on-exit, the report's
|
|
10
|
+
# exit code unless a child exited 2.
|
|
11
|
+
class Supervisor
|
|
12
|
+
FORWARDED_SIGNALS = %w[INT TERM].freeze
|
|
13
|
+
|
|
14
|
+
# @param worker_class [#new] `Worker` by default; must accept
|
|
15
|
+
# `config:, queue_factory:, suite:, out:, err:` and respond to `#run`.
|
|
16
|
+
# @param suite_loader [#call] `(rspec_args, config:, out:, err:) -> suite`
|
|
17
|
+
# used once in the parent for `--boot shared`.
|
|
18
|
+
# @param report_runner [#call] `(args, out:, err:) -> Integer`
|
|
19
|
+
# @param queue_factory_for [#call] `(config) -> lambda` building a queue.
|
|
20
|
+
# @param fork [#call] `Process.fork` (with block) replacement for tests.
|
|
21
|
+
def initialize(config:, out: $stdout, err: $stderr, worker_class: nil, suite_loader: nil,
|
|
22
|
+
report_runner: nil, queue_factory_for: nil, fork: Process.method(:fork),
|
|
23
|
+
fork_available: Process.respond_to?(:fork), env: ENV)
|
|
24
|
+
@config = config
|
|
25
|
+
@out = out
|
|
26
|
+
@err = err
|
|
27
|
+
@worker_class = worker_class
|
|
28
|
+
@suite_loader = suite_loader
|
|
29
|
+
@report_runner = report_runner
|
|
30
|
+
@queue_factory_for = queue_factory_for
|
|
31
|
+
@fork = fork
|
|
32
|
+
@fork_available = fork_available
|
|
33
|
+
@env = env
|
|
34
|
+
@children = {} # pid => n
|
|
35
|
+
@statuses = {} # n => Process::Status
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @return [Integer] exit code
|
|
39
|
+
def run
|
|
40
|
+
return run_inline if @config.processes == 1
|
|
41
|
+
unless @fork_available
|
|
42
|
+
raise ForkUnavailable, "--processes #{@config.processes} needs fork, which this platform lacks"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
remaining, formatter_pairs = CLI::FormatterArgs.split(@config.rspec_args)
|
|
46
|
+
announce_formatter_output(formatter_pairs)
|
|
47
|
+
suite = @config.boot == :shared ? load_shared_suite(remaining) : nil
|
|
48
|
+
with_signal_forwarding do
|
|
49
|
+
1.upto(@config.processes) { |number| start_child(number, remaining, formatter_pairs, suite) }
|
|
50
|
+
wait_for_children
|
|
51
|
+
end
|
|
52
|
+
exit_code
|
|
53
|
+
rescue InfrastructureError => e
|
|
54
|
+
log(e.message)
|
|
55
|
+
e.exit_code
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Children write their formatter output to files, so nothing here prints
|
|
59
|
+
# an RSpec summary per process; the verdict comes from `report`.
|
|
60
|
+
def announce_formatter_output(formatter_pairs)
|
|
61
|
+
return unless CLI::FormatterArgs.defaults_needed?(formatter_pairs)
|
|
62
|
+
|
|
63
|
+
log("formatter output: #{CLI::FormatterArgs::DEFAULT_DIR}/ " \
|
|
64
|
+
"(the build verdict comes from `rspec-hopper report`)")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# The value of TEST_ENV_NUMBER for child `number` (1-based): "" for the
|
|
68
|
+
# first, then "2".."N", matching parallel_tests.
|
|
69
|
+
def self.env_number(number) = number == 1 ? "" : number.to_s
|
|
70
|
+
|
|
71
|
+
def child_worker_id(number) = "#{@config.worker_id}.#{number}"
|
|
72
|
+
|
|
73
|
+
# The config child `number` runs with; public so specs can assert on it.
|
|
74
|
+
def child_config(number, remaining, formatter_pairs)
|
|
75
|
+
env_number = self.class.env_number(number)
|
|
76
|
+
worker_id = child_worker_id(number)
|
|
77
|
+
child_args = CLI::FormatterArgs.for_child(formatter_pairs, env_number, label: worker_id)
|
|
78
|
+
@config.with(
|
|
79
|
+
worker_id: worker_id, supervised: true, processes: 1,
|
|
80
|
+
rspec_args: (child_args + remaining).freeze
|
|
81
|
+
)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def run_inline
|
|
87
|
+
worker_class.new(config: @config, queue_factory: queue_factory_for.call(@config), out: @out, err: @err).run
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def load_shared_suite(remaining)
|
|
91
|
+
@env.delete("TEST_ENV_NUMBER")
|
|
92
|
+
suite = suite_loader.call(remaining, config: @config, out: @out, err: @err)
|
|
93
|
+
unless RSpec::Hopper.after_fork_hooks.any?
|
|
94
|
+
raise SharedBootWithoutHook,
|
|
95
|
+
"--boot shared needs at least one RSpec::Hopper.after_fork hook: the suite was booted once " \
|
|
96
|
+
"with TEST_ENV_NUMBER unset, so each child must re-derive every value computed from it " \
|
|
97
|
+
"(database name, Redis db, ports, paths). Register a hook or use --boot per-process."
|
|
98
|
+
end
|
|
99
|
+
RSpec::Hopper.before_fork_hooks.each(&:call)
|
|
100
|
+
suite
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def start_child(number, remaining, formatter_pairs, suite)
|
|
104
|
+
config = child_config(number, remaining, formatter_pairs)
|
|
105
|
+
env_number = self.class.env_number(number)
|
|
106
|
+
pid = @fork.call { run_child(config, env_number, suite) }
|
|
107
|
+
@children[pid] = number
|
|
108
|
+
log("started #{config.worker_id} (pid #{pid}, TEST_ENV_NUMBER=#{env_number.inspect})")
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def run_child(config, env_number, suite)
|
|
112
|
+
# SYSTEM_DEFAULT, not DEFAULT: Ruby's own default handling for INT and
|
|
113
|
+
# TERM raises Interrupt/SignalException, which prints a stack trace per
|
|
114
|
+
# child before re-signalling. A forwarded signal would therefore bury
|
|
115
|
+
# the run in N backtraces, and an Interrupt raised inside a unit would
|
|
116
|
+
# be caught as a non-requeueable failure rather than stopping the
|
|
117
|
+
# child. The OS default ends the child silently with the same wait
|
|
118
|
+
# status, which is what the parent reports.
|
|
119
|
+
FORWARDED_SIGNALS.each { |sig| trap(sig, "SYSTEM_DEFAULT") }
|
|
120
|
+
@env["TEST_ENV_NUMBER"] = env_number
|
|
121
|
+
RSpec::Hopper.after_fork_hooks.each { |hook| hook.call(env_number) } if @config.boot == :shared
|
|
122
|
+
kwargs = { config: config, queue_factory: queue_factory_for.call(config), out: @out, err: @err }
|
|
123
|
+
kwargs[:suite] = suite unless suite.nil?
|
|
124
|
+
# `exit`, not `exit!`: RSpec formatters may rely on at_exit handlers. An
|
|
125
|
+
# exception escaping the worker ends the child with status 1, which the
|
|
126
|
+
# parent treats as an infrastructure failure.
|
|
127
|
+
exit(worker_class.new(**kwargs).run)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def wait_for_children
|
|
131
|
+
@children.each do |pid, number|
|
|
132
|
+
_, status = Process.wait2(pid)
|
|
133
|
+
@statuses[number] = status
|
|
134
|
+
log("#{child_worker_id(number)} (pid #{pid}) #{describe(status)}")
|
|
135
|
+
rescue Errno::ECHILD
|
|
136
|
+
@statuses[number] = nil
|
|
137
|
+
log("#{child_worker_id(number)} (pid #{pid}) was already reaped; treating as failed")
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def with_signal_forwarding
|
|
142
|
+
previous = FORWARDED_SIGNALS.to_h { |sig| [sig, trap(sig) { forward(sig) }] }
|
|
143
|
+
yield
|
|
144
|
+
ensure
|
|
145
|
+
previous&.each { |sig, handler| trap(sig, handler || "DEFAULT") }
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def forward(sig)
|
|
149
|
+
@children.each_key do |pid|
|
|
150
|
+
next if @statuses.key?(@children[pid])
|
|
151
|
+
|
|
152
|
+
Process.kill(sig, pid)
|
|
153
|
+
rescue Errno::ESRCH
|
|
154
|
+
nil
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def exit_code
|
|
159
|
+
codes = @statuses.values.map { |status| status&.exitstatus }
|
|
160
|
+
return ExitCode::INFRASTRUCTURE if codes.any? { |c| ![ExitCode::OK, ExitCode::ABORTED].include?(c) }
|
|
161
|
+
|
|
162
|
+
aborted = codes.include?(ExitCode::ABORTED)
|
|
163
|
+
return aborted ? ExitCode::ABORTED : ExitCode::OK unless @config.report_on_exit
|
|
164
|
+
|
|
165
|
+
log("a child aborted a hung unit (exit 4); the verdict comes from report") if aborted
|
|
166
|
+
report_runner.call(@config.report_args, out: @out, err: @err)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def describe(status)
|
|
170
|
+
return "exited #{status.exitstatus}" if status.exited?
|
|
171
|
+
return "killed by SIG#{Signal.signame(status.termsig)}" if status.signaled?
|
|
172
|
+
|
|
173
|
+
"stopped with #{status.inspect}"
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def log(message)
|
|
177
|
+
@err.puts "[hopper #{@config.worker_id}] #{message}"
|
|
178
|
+
@err.flush if @err.respond_to?(:flush)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def worker_class = @worker_class || Worker
|
|
182
|
+
|
|
183
|
+
def suite_loader
|
|
184
|
+
@suite_loader || ->(args, config:, out:, err:) { Worker::Suite.load(args, config: config, out: out, err: err) }
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def report_runner
|
|
188
|
+
@report_runner || ->(args, out:, err:) { CLI::Report.run(args, out: out, err: err) }
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def queue_factory_for
|
|
192
|
+
@queue_factory_for || ->(config) { CLI::Work.queue_factory(config) }
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpec
|
|
4
|
+
module Hopper
|
|
5
|
+
UNIT_TYPES = %w[file].freeze
|
|
6
|
+
|
|
7
|
+
# A schedulable unit of work. Phase 1 units are spec files; the `type`
|
|
8
|
+
# field exists so example-level units can be added without a schema change.
|
|
9
|
+
Unit = Data.define(:id, :type) do
|
|
10
|
+
def self.file(path) = new(id: path, type: "file")
|
|
11
|
+
|
|
12
|
+
def to_h = { "id" => id, "type" => type }
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpec
|
|
4
|
+
module Hopper
|
|
5
|
+
class Worker
|
|
6
|
+
# Records every notification RSpec sends while a unit's example groups
|
|
7
|
+
# run, so that the worker can replay them to the real reporter exactly
|
|
8
|
+
# once for a final attempt or discard them for a requeued or stale one.
|
|
9
|
+
# Suite lifecycle notifications (`start`, `finish`, `close`, `report`) are
|
|
10
|
+
# deliberately not defined: they belong to the outer runner.
|
|
11
|
+
class BufferingReporter
|
|
12
|
+
BUFFERED = %i[
|
|
13
|
+
example_group_started example_group_finished
|
|
14
|
+
example_started example_finished example_passed example_failed example_pending
|
|
15
|
+
message publish notify_non_example_exception deprecation
|
|
16
|
+
].freeze
|
|
17
|
+
|
|
18
|
+
attr_reader :events
|
|
19
|
+
|
|
20
|
+
def initialize
|
|
21
|
+
@events = []
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
BUFFERED.each do |name|
|
|
25
|
+
define_method(name) do |*args, **kwargs|
|
|
26
|
+
@events << [name, args, kwargs]
|
|
27
|
+
nil
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# RSpec consults this after every failure; the worker never fails fast.
|
|
32
|
+
def fail_fast_limit_met? = false
|
|
33
|
+
|
|
34
|
+
def size = @events.size
|
|
35
|
+
def empty? = @events.empty?
|
|
36
|
+
|
|
37
|
+
# Sends every recorded notification to `real`, in original order.
|
|
38
|
+
def replay(real)
|
|
39
|
+
@events.each { |name, args, kwargs| real.public_send(name, *args, **kwargs) }
|
|
40
|
+
discard
|
|
41
|
+
self
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def discard
|
|
45
|
+
@events = []
|
|
46
|
+
self
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpec
|
|
4
|
+
module Hopper
|
|
5
|
+
class Worker
|
|
6
|
+
# Keeps a reservation alive while its unit executes. Runs in a thread
|
|
7
|
+
# that renews the reservation every `config.heartbeat_interval` seconds.
|
|
8
|
+
# After `max_unit_duration` it records `abandoned` once and stops
|
|
9
|
+
# renewing; after a further `timeout` it aborts the whole process, since
|
|
10
|
+
# the hung test thread cannot be interrupted safely.
|
|
11
|
+
class Heartbeat
|
|
12
|
+
# How long to wait before retrying a renewal that failed on a Redis
|
|
13
|
+
# connection error, capped by the normal interval.
|
|
14
|
+
RETRY_INTERVAL = 1.0
|
|
15
|
+
|
|
16
|
+
attr_reader :reservation, :abandoned_at, :renewals, :renewal_failures
|
|
17
|
+
|
|
18
|
+
# @param clock [#call] monotonic seconds
|
|
19
|
+
# @param sleeper [#call, nil] waits for the given seconds; nil uses an
|
|
20
|
+
# interruptible wait so `stop` returns promptly
|
|
21
|
+
# @param aborter [#call] receives the exit code; defaults to `exit!`
|
|
22
|
+
def initialize(queue:, reservation:, config:, err: $stderr, clock: nil, sleeper: nil, aborter: nil,
|
|
23
|
+
worker_id: config.worker_id)
|
|
24
|
+
@queue = queue
|
|
25
|
+
@reservation = reservation
|
|
26
|
+
@config = config
|
|
27
|
+
@err = err
|
|
28
|
+
@worker_id = worker_id
|
|
29
|
+
@clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
30
|
+
@sleeper = sleeper
|
|
31
|
+
@aborter = aborter || ->(code) { Kernel.exit!(code) }
|
|
32
|
+
@signal = Thread::Queue.new
|
|
33
|
+
@stopped = false
|
|
34
|
+
@stale = false
|
|
35
|
+
@abandoned_at = nil
|
|
36
|
+
@renewals = 0
|
|
37
|
+
@renewal_failures = 0
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def interval = @config.heartbeat_interval
|
|
41
|
+
def stale? = @stale
|
|
42
|
+
def abandoned? = !@abandoned_at.nil?
|
|
43
|
+
def running? = !@thread.nil? && @thread.alive?
|
|
44
|
+
|
|
45
|
+
# Marks the unit as started at `now` without spawning the thread, so
|
|
46
|
+
# the loop can be driven through `tick`.
|
|
47
|
+
def prime(now = @clock.call)
|
|
48
|
+
@started_at = @last_renewal = now
|
|
49
|
+
self
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def start
|
|
53
|
+
prime
|
|
54
|
+
@thread = Thread.new { run_loop }
|
|
55
|
+
@thread.name = "hopper-heartbeat" if @thread.respond_to?(:name=)
|
|
56
|
+
# Giving up on renewals re-raises through `join` in `stop`, where the
|
|
57
|
+
# worker turns it into one message and exit 2. Ruby's own report would
|
|
58
|
+
# only add a bare backtrace ahead of it.
|
|
59
|
+
@thread.report_on_exception = false
|
|
60
|
+
self
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def stop
|
|
64
|
+
@stopped = true
|
|
65
|
+
@signal << :stop
|
|
66
|
+
@thread&.join
|
|
67
|
+
self
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# One step of the loop, at time `now`. Public so it can be driven
|
|
71
|
+
# without a thread. Returns what happened.
|
|
72
|
+
def tick(now)
|
|
73
|
+
return :stopped if @stopped
|
|
74
|
+
|
|
75
|
+
elapsed = now - @started_at
|
|
76
|
+
if elapsed >= abort_after
|
|
77
|
+
abort!
|
|
78
|
+
:aborted
|
|
79
|
+
elsif elapsed >= @config.max_unit_duration
|
|
80
|
+
abandon!(elapsed)
|
|
81
|
+
elsif now - @last_renewal >= interval
|
|
82
|
+
renew!(now)
|
|
83
|
+
else
|
|
84
|
+
:idle
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def abort_after = @config.max_unit_duration + @config.timeout
|
|
91
|
+
|
|
92
|
+
def run_loop
|
|
93
|
+
until @stopped
|
|
94
|
+
result = tick(@clock.call)
|
|
95
|
+
break if %i[aborted stale].include?(result)
|
|
96
|
+
|
|
97
|
+
wait(result == :retrying ? [RETRY_INTERVAL, interval].min : interval)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def wait(seconds)
|
|
102
|
+
if @sleeper
|
|
103
|
+
@sleeper.call(seconds)
|
|
104
|
+
else
|
|
105
|
+
@signal.pop(timeout: seconds)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def renew!(now)
|
|
110
|
+
@queue.heartbeat(@reservation)
|
|
111
|
+
@last_renewal = now
|
|
112
|
+
@renewals += 1
|
|
113
|
+
recovered if @renewal_failures.positive?
|
|
114
|
+
:renewed
|
|
115
|
+
rescue StaleReservation
|
|
116
|
+
@stale = true
|
|
117
|
+
:stale
|
|
118
|
+
rescue REDIS_ERROR => e
|
|
119
|
+
failed_renewal(e, now)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# A connection error is not yet a lost unit: the reservation stays this
|
|
123
|
+
# worker's until `timeout` passes without a renewal. Retry until then
|
|
124
|
+
# rather than killing a worker that is running tests fine, and give up
|
|
125
|
+
# once the entry is reclaimable, when carrying on would only let a
|
|
126
|
+
# sibling run the unit while this process still owns its output.
|
|
127
|
+
def failed_renewal(error, now)
|
|
128
|
+
@renewal_failures += 1
|
|
129
|
+
raise error if now - @last_renewal >= @config.timeout
|
|
130
|
+
|
|
131
|
+
if @renewal_failures == 1
|
|
132
|
+
warn_heartbeat "heartbeat failed (#{error.class}: #{error.message}); " \
|
|
133
|
+
"retrying for up to #{@config.timeout}s"
|
|
134
|
+
end
|
|
135
|
+
:retrying
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def recovered
|
|
139
|
+
warn_heartbeat "heartbeat recovered after #{@renewal_failures} failed " \
|
|
140
|
+
"#{@renewal_failures == 1 ? "attempt" : "attempts"}"
|
|
141
|
+
@renewal_failures = 0
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def warn_heartbeat(message)
|
|
145
|
+
@err.puts "[hopper #{@worker_id}] #{@reservation.unit_id}: #{message}"
|
|
146
|
+
@err.flush if @err.respond_to?(:flush)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# The `abandoned` event is a warning, not a terminal state, so a Redis
|
|
150
|
+
# error while recording it retries on the next tick and never ends the
|
|
151
|
+
# worker: the abort at `max_unit_duration + timeout` still fires.
|
|
152
|
+
def abandon!(elapsed)
|
|
153
|
+
return :abandoned if @abandoned_at
|
|
154
|
+
|
|
155
|
+
@queue.record_abandoned(@reservation, elapsed_ms: (elapsed * 1000).round)
|
|
156
|
+
@abandoned_at = elapsed
|
|
157
|
+
:abandoned
|
|
158
|
+
rescue StaleReservation
|
|
159
|
+
@stale = true
|
|
160
|
+
:stale
|
|
161
|
+
rescue REDIS_ERROR
|
|
162
|
+
:retrying
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def abort!
|
|
166
|
+
@err.puts "[hopper #{@worker_id}] Aborting worker: #{@reservation.unit_id} exceeded " \
|
|
167
|
+
"#{format_seconds(@config.max_unit_duration)}s"
|
|
168
|
+
@err.flush if @err.respond_to?(:flush)
|
|
169
|
+
@aborter.call(ExitCode::ABORTED)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def format_seconds(value)
|
|
173
|
+
value == value.to_i ? value.to_i : value
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpec
|
|
4
|
+
module Hopper
|
|
5
|
+
class Worker
|
|
6
|
+
# Decides, from the execution results of a unit's selected examples,
|
|
7
|
+
# whether a failed attempt may be requeued. An attempt is a requeue
|
|
8
|
+
# candidate only if every failure in it is requeueable.
|
|
9
|
+
module RequeuePolicy
|
|
10
|
+
# These never justify a retry; one anywhere in the unit makes the
|
|
11
|
+
# attempt final. They also escape `Example#run` rather than being
|
|
12
|
+
# recorded on the example, so the worker passes them in as `escaped`.
|
|
13
|
+
NON_REQUEUEABLE = [SystemExit, Interrupt, SignalException, NoMemoryError].freeze
|
|
14
|
+
|
|
15
|
+
Decision = Data.define(:status, :failed_examples, :unexecuted, :escaped) do
|
|
16
|
+
def passed? = status == :passed
|
|
17
|
+
def requeue_candidate? = status == :requeue_candidate
|
|
18
|
+
def final_failure? = status == :final_failure
|
|
19
|
+
|
|
20
|
+
def failure_count = failed_examples.size + (escaped ? 1 : 0)
|
|
21
|
+
|
|
22
|
+
def summary = RequeuePolicy.failure_summary(failure_count)
|
|
23
|
+
|
|
24
|
+
# Size-capped error payload for the attempt log.
|
|
25
|
+
def errors
|
|
26
|
+
entries = failed_examples.flat_map do |example|
|
|
27
|
+
RequeuePolicy.exceptions_of(example).map do |exception|
|
|
28
|
+
ErrorPayload.from_exception(exception, example_id: example.id, description: example.full_description)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
entries << escaped_payload if escaped
|
|
32
|
+
ErrorPayload.cap(entries)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def escaped_payload
|
|
38
|
+
example = unexecuted.find { |ex| ex.execution_result.started_at }
|
|
39
|
+
ErrorPayload.from_exception(escaped, example_id: example&.id, description: example&.full_description)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
module_function
|
|
44
|
+
|
|
45
|
+
# @param examples [Array<RSpec::Core::Example>] the unit's selected examples
|
|
46
|
+
# @param escaped [Exception, nil] an exception that escaped `ExampleGroup.run`
|
|
47
|
+
def decide(examples, escaped: nil)
|
|
48
|
+
failed = examples.select { |ex| ex.execution_result.status == :failed }
|
|
49
|
+
unexecuted = examples.select { |ex| ex.execution_result.status.nil? }
|
|
50
|
+
Decision.new(status: status_for(failed, escaped), failed_examples: failed, unexecuted: unexecuted,
|
|
51
|
+
escaped: escaped)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def status_for(failed, escaped)
|
|
55
|
+
return :final_failure if escaped
|
|
56
|
+
return :passed if failed.empty?
|
|
57
|
+
return :requeue_candidate if failed.all? { |ex| exceptions_of(ex).all? { |e| requeueable?(e) } }
|
|
58
|
+
|
|
59
|
+
:final_failure
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def requeueable?(exception)
|
|
63
|
+
NON_REQUEUEABLE.none? { |klass| exception.is_a?(klass) }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Every exception behind a failed example, with aggregate errors
|
|
67
|
+
# (`MultipleExceptionError` and friends) flattened.
|
|
68
|
+
def exceptions_of(example)
|
|
69
|
+
flatten(example.execution_result.exception || example.exception)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def flatten(exception)
|
|
73
|
+
return [] if exception.nil?
|
|
74
|
+
return [exception] unless exception.respond_to?(:all_exceptions)
|
|
75
|
+
|
|
76
|
+
nested = exception.all_exceptions.flat_map { |e| flatten(e) }
|
|
77
|
+
nested.empty? ? [exception] : nested
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def failure_summary(count)
|
|
81
|
+
"#{count} failure#{"s" unless count == 1}"
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rspec/core"
|
|
4
|
+
|
|
5
|
+
module RSpec
|
|
6
|
+
module Hopper
|
|
7
|
+
class Worker
|
|
8
|
+
# The RSpec runner the worker drives. `configure` is inherited unchanged;
|
|
9
|
+
# `run_specs` is replaced so that the reporter lifecycle (`start`,
|
|
10
|
+
# `finish`, `close`) and the per-process suite hooks fire exactly once
|
|
11
|
+
# around the hopper loop instead of around `ordered_example_groups`.
|
|
12
|
+
#
|
|
13
|
+
# Runner#setup is not used: its `ensure world.announce_filters` calls
|
|
14
|
+
# `reporter.abort_with` (an `exit!`) for `--only-failures` before the
|
|
15
|
+
# worker could reject the option, and load-error capture has to be
|
|
16
|
+
# scoped to `load_spec_files` alone. Suite performs those steps itself.
|
|
17
|
+
class Runner < RSpec::Core::Runner
|
|
18
|
+
# @param expected_example_count [Integer] the manifest's total examples
|
|
19
|
+
# @yield [RSpec::Core::Reporter] the real reporter, inside suite hooks
|
|
20
|
+
def run_specs(expected_example_count)
|
|
21
|
+
@configuration.reporter.report(expected_example_count) do |reporter|
|
|
22
|
+
@configuration.with_suite_hooks { yield reporter }
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|