rspec-core-parallel 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 +11 -0
- data/LICENSE.md +27 -0
- data/README.md +148 -0
- data/exe/rspec-parallel +8 -0
- data/lib/rspec/core/parallel/channel.rb +58 -0
- data/lib/rspec/core/parallel/coordinator.rb +428 -0
- data/lib/rspec/core/parallel/core_ext.rb +253 -0
- data/lib/rspec/core/parallel/exception_serializer.rb +122 -0
- data/lib/rspec/core/parallel/rails_integration.rb +49 -0
- data/lib/rspec/core/parallel/registry.rb +45 -0
- data/lib/rspec/core/parallel/scheduler.rb +162 -0
- data/lib/rspec/core/parallel/simplecov_integration.rb +30 -0
- data/lib/rspec/core/parallel/version.rb +7 -0
- data/lib/rspec/core/parallel/worker.rb +187 -0
- data/lib/rspec/core/parallel/worker_reporter.rb +212 -0
- data/lib/rspec/core/parallel.rb +95 -0
- data/lib/rspec-core-parallel.rb +1 -0
- metadata +85 -0
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
require 'rspec/core/bisect/shell_command'
|
|
2
|
+
require 'rspec/core/bisect/fork_runner'
|
|
3
|
+
|
|
4
|
+
module RSpec
|
|
5
|
+
module Core
|
|
6
|
+
module Parallel
|
|
7
|
+
# The changes rspec-core needs to run a suite in parallel, applied to
|
|
8
|
+
# rspec-core 3.13 from outside.
|
|
9
|
+
#
|
|
10
|
+
# @private
|
|
11
|
+
module CoreExt
|
|
12
|
+
# @private
|
|
13
|
+
module Configuration
|
|
14
|
+
# @private
|
|
15
|
+
DEFAULT_PARALLEL_RUN_TIMES_FILE_PATH = 'tmp/rspec_parallel_run_times.txt'
|
|
16
|
+
|
|
17
|
+
def self.included(configuration)
|
|
18
|
+
# @macro add_setting
|
|
19
|
+
# Number of worker processes used to run the suite (default: 1,
|
|
20
|
+
# which runs the suite sequentially in the current process). The
|
|
21
|
+
# suite is loaded once and then forked, so every worker is a
|
|
22
|
+
# separate process: examples running concurrently never share
|
|
23
|
+
# global state. Each worker runs `before(:suite)` and
|
|
24
|
+
# `after(:suite)` hooks, and gets a `TEST_ENV_NUMBER` environment
|
|
25
|
+
# variable (`""`, `"2"`, `"3"`...) which can be used to give every
|
|
26
|
+
# worker its own database.
|
|
27
|
+
# @return [Integer]
|
|
28
|
+
configuration.add_setting :parallel_workers
|
|
29
|
+
|
|
30
|
+
# @macro add_setting
|
|
31
|
+
# Allows parallel runs to split groups having `before(:context)` or
|
|
32
|
+
# `after(:context)` hooks across workers (default: `false`).
|
|
33
|
+
#
|
|
34
|
+
# A top-level group expected to take longer than a worker's share
|
|
35
|
+
# of the suite is split into its child groups and parts of its
|
|
36
|
+
# examples, which run in different workers. By default only groups
|
|
37
|
+
# without context hooks of their own (global ones are fine) are
|
|
38
|
+
# split, since the context hooks of a split group run once for each
|
|
39
|
+
# part of it, rather than once. This is how they run when running
|
|
40
|
+
# only part of a group with `rspec path/to/spec.rb:42`, so it is
|
|
41
|
+
# safe for most suites and lets long groups be spread over workers.
|
|
42
|
+
#
|
|
43
|
+
# Tag a group with `:parallel_split => false` to always run it in a
|
|
44
|
+
# single worker.
|
|
45
|
+
# @return [Boolean]
|
|
46
|
+
configuration.add_setting :parallel_split_groups_with_context_hooks
|
|
47
|
+
|
|
48
|
+
configuration.__send__(:attr_writer, :reporter, :parallel_run_times_file_path)
|
|
49
|
+
|
|
50
|
+
# @return [Integer, nil] the number of the current parallel worker
|
|
51
|
+
# (starting at 1), or nil when not running in a parallel worker.
|
|
52
|
+
configuration.__send__(:attr_accessor, :parallel_worker_number)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# The file in which parallel runs persist the run time of every
|
|
56
|
+
# example, so that the next parallel runs can hand out the longest
|
|
57
|
+
# groups first and spread them over workers. Defaults to
|
|
58
|
+
# `tmp/rspec_parallel_run_times.txt`, or to
|
|
59
|
+
# `example_status_persistence_file_path` when it is set (the run
|
|
60
|
+
# times persisted there are used). Set it to `nil` to not persist
|
|
61
|
+
# run times.
|
|
62
|
+
#
|
|
63
|
+
# @return [String, nil] the file path
|
|
64
|
+
def parallel_run_times_file_path
|
|
65
|
+
return @parallel_run_times_file_path if defined?(@parallel_run_times_file_path)
|
|
66
|
+
|
|
67
|
+
DEFAULT_PARALLEL_RUN_TIMES_FILE_PATH unless example_status_persistence_file_path
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Registers a block to run in each parallel worker process after it
|
|
71
|
+
# is forked, before its `before(:suite)` hooks. The block receives
|
|
72
|
+
# the worker number. Use it to set up per-worker resources, such as
|
|
73
|
+
# connecting to a per-worker database.
|
|
74
|
+
#
|
|
75
|
+
# @example
|
|
76
|
+
# RSpec.configure do |config|
|
|
77
|
+
# config.parallel_worker_setup do |worker_number|
|
|
78
|
+
# SimpleCov.command_name "rspec-#{worker_number}"
|
|
79
|
+
# end
|
|
80
|
+
# end
|
|
81
|
+
def parallel_worker_setup(&block)
|
|
82
|
+
parallel_worker_hooks(:setup) << block
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Registers a block to run in each parallel worker process after its
|
|
86
|
+
# `after(:suite)` hooks, right before the worker exits. The block
|
|
87
|
+
# receives the worker number.
|
|
88
|
+
def parallel_worker_teardown(&block)
|
|
89
|
+
parallel_worker_hooks(:teardown) << block
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# @private
|
|
93
|
+
def parallel_worker_hooks(type)
|
|
94
|
+
(@parallel_worker_hooks ||= { :setup => [], :teardown => [] })[type]
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# @private
|
|
99
|
+
module ConfigurationOverrides
|
|
100
|
+
# Loading this gem means running in parallel: one worker per physical
|
|
101
|
+
# core unless set (`1` runs the suite sequentially). Where processes
|
|
102
|
+
# can't be forked, the suite runs sequentially without a warning.
|
|
103
|
+
def parallel_workers
|
|
104
|
+
value = super
|
|
105
|
+
return value unless value.nil?
|
|
106
|
+
|
|
107
|
+
Parallel.supported? ? Parallel.default_worker_count : 1
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
# rspec-core runs the default path (`spec`) when no files are given
|
|
113
|
+
# only when its command is `rspec`.
|
|
114
|
+
def command
|
|
115
|
+
super == 'rspec-parallel' ? 'rspec' : super
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# @private
|
|
120
|
+
module Parser
|
|
121
|
+
def parse(source=nil)
|
|
122
|
+
# The long form of an option with an optional argument consumes the
|
|
123
|
+
# next argument even when it is a file path, the short form does not.
|
|
124
|
+
@original_args = original_args.map { |arg| arg == '--parallel' ? '-j' : arg }
|
|
125
|
+
super
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
private
|
|
129
|
+
|
|
130
|
+
def parser(options)
|
|
131
|
+
super.tap do |parser|
|
|
132
|
+
parser.separator ''
|
|
133
|
+
parser.separator ' **** Parallel ****'
|
|
134
|
+
parser.separator ''
|
|
135
|
+
parser.on('-j', '--[no-]parallel [COUNT]', Integer,
|
|
136
|
+
'Run the suite in COUNT forked worker processes (default: number of physical CPU cores).') do |count|
|
|
137
|
+
options[:parallel_workers] = count.nil? ? Parallel.default_worker_count : (count || 1)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# @private
|
|
144
|
+
module Runner
|
|
145
|
+
def run_specs(example_groups)
|
|
146
|
+
parallel_units = Parallel.units_for(@configuration, @world, example_groups)
|
|
147
|
+
return super unless parallel_units
|
|
148
|
+
|
|
149
|
+
examples_count = @world.example_count(example_groups)
|
|
150
|
+
examples_passed = @configuration.reporter.report(examples_count) do |reporter|
|
|
151
|
+
Parallel.run(@configuration, @world, parallel_units, reporter)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
exit_code(examples_passed)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# @private
|
|
159
|
+
module HookCollections
|
|
160
|
+
# Whether any `before(:context)` or `after(:context)` hook, other than
|
|
161
|
+
# those registered from `globals`, is registered directly on the
|
|
162
|
+
# owner of these hooks.
|
|
163
|
+
def context_hooks?(globals)
|
|
164
|
+
[:before, :after].any? do |position|
|
|
165
|
+
(all_hooks_for(position, :context) - globals.all_hooks_for(position, :context)).any?
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# @private
|
|
171
|
+
module Example
|
|
172
|
+
# Sets the outcome of running this example in another process.
|
|
173
|
+
def restore_exception(exception)
|
|
174
|
+
@exception = exception
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# @private
|
|
179
|
+
module ExampleGroupClassMethods
|
|
180
|
+
# The memoized `let` values of the instance running a split group's
|
|
181
|
+
# `before(:context)` hooks must not be shared with its examples.
|
|
182
|
+
def each_instance_variable_for_example(group)
|
|
183
|
+
super { |ivar| yield ivar unless ivar.to_sym == :@__memoized }
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# @private
|
|
188
|
+
module Reporter
|
|
189
|
+
# Overrides the time profiled for a top-level group, for groups run
|
|
190
|
+
# in another process.
|
|
191
|
+
def record_group_run_time(group, run_time)
|
|
192
|
+
return unless @profiler && @profiler.example_groups.key?(group)
|
|
193
|
+
|
|
194
|
+
@profiler.example_groups[group][:total_time] = run_time
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Order dependencies can only be reproduced in a single process.
|
|
199
|
+
#
|
|
200
|
+
# @private
|
|
201
|
+
module BisectShellCommand
|
|
202
|
+
PARALLEL_OPTION = /\A(-j\d*|--(no-)?parallel(=\d+)?)\z/
|
|
203
|
+
|
|
204
|
+
def initialize(original_cli_args)
|
|
205
|
+
super(without_parallel_options(original_cli_args))
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# The bisect runs load this gem, so that a `--parallel` in `.rspec`
|
|
209
|
+
# is a known option there too.
|
|
210
|
+
def command_for(locations, server)
|
|
211
|
+
executable = open3_safe_escape(RSpec::Core.path_to_executable)
|
|
212
|
+
"#{super.sub(executable) { "-rrspec/core/parallel #{executable}" }} --no-parallel"
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
private
|
|
216
|
+
|
|
217
|
+
def without_parallel_options(args)
|
|
218
|
+
kept = []
|
|
219
|
+
count_may_follow = false
|
|
220
|
+
args.each do |arg|
|
|
221
|
+
next count_may_follow = false if count_may_follow && arg =~ /\A\d+\z/
|
|
222
|
+
|
|
223
|
+
count_may_follow = %w[-j --parallel].include?(arg)
|
|
224
|
+
kept << arg unless arg =~ PARALLEL_OPTION
|
|
225
|
+
end
|
|
226
|
+
kept
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Order dependencies can only be reproduced in a single process.
|
|
231
|
+
#
|
|
232
|
+
# @private
|
|
233
|
+
module BisectRunDispatcher
|
|
234
|
+
def initialize(runner, channel)
|
|
235
|
+
super
|
|
236
|
+
runner.configuration.force(:parallel_workers => 1)
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
Configuration.__send__(:include, Parallel::CoreExt::Configuration)
|
|
243
|
+
Configuration.__send__(:prepend, Parallel::CoreExt::ConfigurationOverrides)
|
|
244
|
+
Parser.__send__(:prepend, Parallel::CoreExt::Parser)
|
|
245
|
+
Runner.__send__(:prepend, Parallel::CoreExt::Runner)
|
|
246
|
+
Hooks::HookCollections.__send__(:include, Parallel::CoreExt::HookCollections)
|
|
247
|
+
Example.__send__(:include, Parallel::CoreExt::Example)
|
|
248
|
+
ExampleGroup.singleton_class.__send__(:prepend, Parallel::CoreExt::ExampleGroupClassMethods)
|
|
249
|
+
Reporter.__send__(:include, Parallel::CoreExt::Reporter)
|
|
250
|
+
Bisect::ShellCommand.__send__(:prepend, Parallel::CoreExt::BisectShellCommand)
|
|
251
|
+
Bisect::ForkRunner::RunDispatcher.__send__(:prepend, Parallel::CoreExt::BisectRunDispatcher)
|
|
252
|
+
end
|
|
253
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
module RSpec
|
|
2
|
+
module Core
|
|
3
|
+
module Parallel
|
|
4
|
+
# Carries exceptions raised in a worker over to the main process.
|
|
5
|
+
#
|
|
6
|
+
# Exceptions are Marshal-ed whenever possible so that the main process
|
|
7
|
+
# sees the very same object (class, ivars, cause...). Exceptions holding
|
|
8
|
+
# unmarshalable state (procs, IOs, singleton methods...) or whose class
|
|
9
|
+
# does not exist in the main process are rebuilt from their class name,
|
|
10
|
+
# message, backtrace and cause.
|
|
11
|
+
#
|
|
12
|
+
# @private
|
|
13
|
+
module ExceptionSerializer
|
|
14
|
+
MAX_CAUSE_DEPTH = 10
|
|
15
|
+
|
|
16
|
+
# Stands in for exceptions that cannot be reconstructed as instances
|
|
17
|
+
# of their original class.
|
|
18
|
+
class RemoteException < StandardError
|
|
19
|
+
class << self
|
|
20
|
+
attr_accessor :remote_class_name
|
|
21
|
+
|
|
22
|
+
def name
|
|
23
|
+
remote_class_name || super
|
|
24
|
+
end
|
|
25
|
+
alias_method :to_s, :name
|
|
26
|
+
alias_method :inspect, :name
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.dump(exception, depth=0)
|
|
31
|
+
return nil unless exception
|
|
32
|
+
|
|
33
|
+
{
|
|
34
|
+
:marshaled => marshal(exception),
|
|
35
|
+
:class_name => exception.class.name,
|
|
36
|
+
:message => safe_message(exception),
|
|
37
|
+
:backtrace => exception.backtrace,
|
|
38
|
+
:cause => depth < MAX_CAUSE_DEPTH ? dump(exception.cause, depth + 1) : nil
|
|
39
|
+
}
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.load(payload)
|
|
43
|
+
return nil unless payload
|
|
44
|
+
|
|
45
|
+
unmarshal(payload[:marshaled]) || rebuild(payload)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.marshal(exception)
|
|
49
|
+
Marshal.dump(exception)
|
|
50
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
51
|
+
nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def self.unmarshal(data)
|
|
55
|
+
return nil unless data
|
|
56
|
+
|
|
57
|
+
exception = Marshal.load(data) # rubocop:disable Security/MarshalLoad
|
|
58
|
+
exception if Exception === exception && usable?(exception)
|
|
59
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
60
|
+
nil
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def self.safe_message(exception)
|
|
64
|
+
exception.message.to_s
|
|
65
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
66
|
+
"<#{exception.class.name}#message raised #{e.class}>"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Rebuilds the exception as an instance of its class when possible.
|
|
70
|
+
# Classes whose methods rely on state set by their constructor (such
|
|
71
|
+
# as a wrapped exception) get a stand-in reporting their name instead.
|
|
72
|
+
def self.rebuild(payload)
|
|
73
|
+
cause = load(payload[:cause])
|
|
74
|
+
klass = exception_class(payload[:class_name])
|
|
75
|
+
|
|
76
|
+
if klass && !klass.method_defined?(:all_exceptions)
|
|
77
|
+
exception = build(klass.allocate, payload, cause)
|
|
78
|
+
return exception if usable?(exception)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
proxy = Class.new(RemoteException)
|
|
82
|
+
proxy.remote_class_name = payload[:class_name]
|
|
83
|
+
build(proxy.allocate, payload, cause)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def self.build(exception, payload, cause)
|
|
87
|
+
message = payload[:message]
|
|
88
|
+
Exception.instance_method(:initialize).bind_call(exception, message)
|
|
89
|
+
exception.define_singleton_method(:message) { message }
|
|
90
|
+
exception.define_singleton_method(:to_s) { message }
|
|
91
|
+
exception = attach_cause(exception, cause) if cause
|
|
92
|
+
exception.set_backtrace(payload[:backtrace]) if payload[:backtrace]
|
|
93
|
+
exception
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def self.usable?(exception)
|
|
97
|
+
exception.message
|
|
98
|
+
exception.backtrace
|
|
99
|
+
exception.inspect
|
|
100
|
+
true
|
|
101
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
102
|
+
false
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def self.exception_class(class_name)
|
|
106
|
+
return nil unless class_name
|
|
107
|
+
|
|
108
|
+
klass = Object.const_get(class_name)
|
|
109
|
+
klass if Class === klass && klass <= Exception
|
|
110
|
+
rescue NameError, ArgumentError
|
|
111
|
+
nil
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def self.attach_cause(exception, cause)
|
|
115
|
+
raise exception, :cause => cause
|
|
116
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
117
|
+
e
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
module RSpec
|
|
2
|
+
module Core
|
|
3
|
+
module Parallel
|
|
4
|
+
# Prepares Rails apps for running in parallel workers, as `rails test`
|
|
5
|
+
# does:
|
|
6
|
+
#
|
|
7
|
+
# * runs the hooks Rails provides to parallel test runners, so that
|
|
8
|
+
# every worker gets its own test database (named after the
|
|
9
|
+
# configured one: `app_test_0`, `app_test_1`..., created and loaded
|
|
10
|
+
# from the schema when needed). Set
|
|
11
|
+
# `ActiveSupport.parallelize_test_databases` to `false` to opt out.
|
|
12
|
+
# Workers are numbered from 0 for these hooks, as in Rails. Rails
|
|
13
|
+
# versions before 8.1 have no hooks to run before forking.
|
|
14
|
+
# * loads the translations before forking, as Rails does when eager
|
|
15
|
+
# loading, rather than in every worker.
|
|
16
|
+
#
|
|
17
|
+
# @private
|
|
18
|
+
module RailsIntegration
|
|
19
|
+
def self.before_fork
|
|
20
|
+
::I18n.eager_load! if defined?(::Rails.application) && ::Rails.application && defined?(::I18n.eager_load!)
|
|
21
|
+
hooks(:before_fork_hooks).each(&:call)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.after_fork(worker_number)
|
|
25
|
+
hooks(:after_fork_hooks).each { |hook| hook.call(worker_number - 1) }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.run_cleanup(worker_number)
|
|
29
|
+
hooks(:run_cleanup_hooks).each { |hook| hook.call(worker_number - 1) }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.hooks(type)
|
|
33
|
+
# Loading it registers the hooks creating a database per worker, as
|
|
34
|
+
# `rails/test_help` does.
|
|
35
|
+
require 'active_record/test_databases' if defined?(::ActiveRecord::TestDatabases)
|
|
36
|
+
return [] unless defined?(::ActiveSupport::Testing::Parallelization)
|
|
37
|
+
return [] unless ::ActiveSupport::Testing::Parallelization.respond_to?(type)
|
|
38
|
+
# Rails checks this setting in its hooks only from version 8.1 on.
|
|
39
|
+
return [] if ::ActiveSupport.respond_to?(:parallelize_test_databases) &&
|
|
40
|
+
::ActiveSupport.parallelize_test_databases == false
|
|
41
|
+
|
|
42
|
+
::ActiveSupport::Testing::Parallelization.public_send(type)
|
|
43
|
+
rescue LoadError
|
|
44
|
+
[]
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
module RSpec
|
|
2
|
+
module Core
|
|
3
|
+
module Parallel
|
|
4
|
+
# Assigns every example group and example a stable index. Workers are
|
|
5
|
+
# forked after the suite is loaded, so walking the same tree yields the
|
|
6
|
+
# same indices in every process, letting processes refer to groups and
|
|
7
|
+
# examples without relying on their ids being unique.
|
|
8
|
+
#
|
|
9
|
+
# @private
|
|
10
|
+
class Registry
|
|
11
|
+
attr_reader :groups, :examples
|
|
12
|
+
|
|
13
|
+
def initialize(top_level_groups)
|
|
14
|
+
@groups = []
|
|
15
|
+
@examples = []
|
|
16
|
+
@group_indices = {}.compare_by_identity
|
|
17
|
+
@example_indices = {}.compare_by_identity
|
|
18
|
+
top_level_groups.each { |group| register(group) }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def group_index(group)
|
|
22
|
+
@group_indices[group]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def example_index(example)
|
|
26
|
+
@example_indices[example]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def register(group)
|
|
32
|
+
@group_indices[group] = @groups.size
|
|
33
|
+
@groups << group
|
|
34
|
+
|
|
35
|
+
group.examples.each do |example|
|
|
36
|
+
@example_indices[example] = @examples.size
|
|
37
|
+
@examples << example
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
group.children.each { |child| register(child) }
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
module RSpec
|
|
2
|
+
module Core
|
|
3
|
+
module Parallel
|
|
4
|
+
# Splits the suite into units of work and orders them longest first, so
|
|
5
|
+
# that the longest units do not end up running alone at the end of the
|
|
6
|
+
# run. Durations come from the run times stored in
|
|
7
|
+
# `example_status_persistence_file_path` and
|
|
8
|
+
# `parallel_run_times_file_path`; examples without a stored run time are
|
|
9
|
+
# assumed to take the average time of those with one.
|
|
10
|
+
#
|
|
11
|
+
# A unit is usually a whole top-level group. A group expected to take
|
|
12
|
+
# longer than a worker's share of the suite is split into its child
|
|
13
|
+
# groups and parts of its own examples, unless it is tagged with
|
|
14
|
+
# `:parallel_split => false` or has `before(:context)` or
|
|
15
|
+
# `after(:context)` hooks other than global ones (see
|
|
16
|
+
# `Configuration#parallel_split_groups_with_context_hooks`). The units
|
|
17
|
+
# of a split group are kept adjacent so that its output stays together.
|
|
18
|
+
#
|
|
19
|
+
# @private
|
|
20
|
+
class Scheduler
|
|
21
|
+
# @!attribute group
|
|
22
|
+
# @return [Class] the group to run
|
|
23
|
+
# @!attribute examples_only
|
|
24
|
+
# @return [Boolean] whether to run only the group's own examples,
|
|
25
|
+
# and not its child groups
|
|
26
|
+
# @!attribute split_ancestors
|
|
27
|
+
# @return [Array<Class>] the split groups containing `group`,
|
|
28
|
+
# outermost first
|
|
29
|
+
# @!attribute examples
|
|
30
|
+
# @return [Array<Example>, nil] the examples to run when running
|
|
31
|
+
# only some of the group's own examples
|
|
32
|
+
# @!attribute sequence
|
|
33
|
+
# @return [Integer] the position of the unit among the parts of its
|
|
34
|
+
# top-level group when running them sequentially
|
|
35
|
+
Unit = Struct.new(:group, :examples_only, :split_ancestors, :estimate, :examples, :sequence)
|
|
36
|
+
|
|
37
|
+
def initialize(configuration, world)
|
|
38
|
+
@configuration = configuration
|
|
39
|
+
@world = world
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Without run times from a previous run, the duration of groups is
|
|
43
|
+
# unknown: they are split into smaller parts, so that a long one does
|
|
44
|
+
# not end up running alone at the end of the run.
|
|
45
|
+
UNKNOWN_DURATIONS_SPLIT_FACTOR = 16
|
|
46
|
+
|
|
47
|
+
# @return [Array<Unit>] the units to run, longest first
|
|
48
|
+
def units(example_groups, worker_count)
|
|
49
|
+
top_level_units = example_groups.filter_map do |group|
|
|
50
|
+
examples = examples_of(group)
|
|
51
|
+
Unit.new(group, false, [], estimated_duration(examples)) unless examples.empty?
|
|
52
|
+
end
|
|
53
|
+
target = top_level_units.sum(&:estimate) / worker_count
|
|
54
|
+
target /= UNKNOWN_DURATIONS_SPLIT_FACTOR if run_times.empty?
|
|
55
|
+
|
|
56
|
+
families = top_level_units.map do |unit|
|
|
57
|
+
family = split(unit, target)
|
|
58
|
+
family.each_with_index { |part, sequence| part.sequence = sequence }
|
|
59
|
+
family.each_with_index.sort_by { |part, sequence| [-part.estimate, sequence] }.map(&:first)
|
|
60
|
+
end
|
|
61
|
+
sort_longest_first(families).flatten
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
DURATION_UNITS = { 'second' => 1, 'minute' => 60 }.freeze
|
|
65
|
+
|
|
66
|
+
# Parses durations formatted by `Formatters::Helpers.format_duration`.
|
|
67
|
+
def self.parse_duration(text)
|
|
68
|
+
parts = text.to_s.scan(/([\d.]+) (second|minute)s?/)
|
|
69
|
+
return nil if parts.empty?
|
|
70
|
+
|
|
71
|
+
parts.inject(0.0) { |total, (amount, unit)| total + (amount.to_f * DURATION_UNITS.fetch(unit)) }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
# @return [Array<Unit>] the parts of the unit, in the order in which
|
|
77
|
+
# they would run sequentially (with the default ordering)
|
|
78
|
+
def split(unit, target)
|
|
79
|
+
return [unit] unless unit.estimate > target && splittable?(unit.group)
|
|
80
|
+
|
|
81
|
+
units = parts_of(unit, target).flat_map { |part| split(part, target) }
|
|
82
|
+
units.size > 1 ? units : [unit]
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def splittable?(group)
|
|
86
|
+
return false if group.metadata[:parallel_split] == false
|
|
87
|
+
|
|
88
|
+
@configuration.parallel_split_groups_with_context_hooks? || !group.hooks.context_hooks?(@configuration.hooks)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def parts_of(unit, target)
|
|
92
|
+
return example_chunks(unit, target) if unit.examples_only
|
|
93
|
+
|
|
94
|
+
ancestors = unit.split_ancestors + [unit.group]
|
|
95
|
+
own_examples = @world.filtered_examples[unit.group]
|
|
96
|
+
parts = own_examples.empty? ? [] : [Unit.new(unit.group, true, ancestors, estimated_duration(own_examples))]
|
|
97
|
+
|
|
98
|
+
unit.group.children.each do |child|
|
|
99
|
+
examples = examples_of(child)
|
|
100
|
+
parts << Unit.new(child, false, ancestors, estimated_duration(examples)) unless examples.empty?
|
|
101
|
+
end
|
|
102
|
+
parts
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Cuts the examples of a unit into parts expected to take at most
|
|
106
|
+
# `target` each (unless a single example takes longer).
|
|
107
|
+
def example_chunks(unit, target)
|
|
108
|
+
chunks = []
|
|
109
|
+
(unit.examples || @world.filtered_examples[unit.group]).each do |example|
|
|
110
|
+
run_time = run_time_of(example)
|
|
111
|
+
if chunks.empty? || chunks.last.estimate + run_time > target
|
|
112
|
+
chunks << Unit.new(unit.group, true, unit.split_ancestors, 0.0, [])
|
|
113
|
+
end
|
|
114
|
+
chunks.last.examples << example
|
|
115
|
+
chunks.last.estimate += run_time
|
|
116
|
+
end
|
|
117
|
+
chunks.size > 1 ? chunks : []
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Families of units run longest first: those with the longest unit,
|
|
121
|
+
# then those taking the longest overall.
|
|
122
|
+
def sort_longest_first(families)
|
|
123
|
+
families.each_with_index.sort_by do |family, position|
|
|
124
|
+
[-family.first.estimate, -family.sum(&:estimate), position]
|
|
125
|
+
end.map(&:first)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def examples_of(group)
|
|
129
|
+
group.descendants.flat_map { |descendant| @world.filtered_examples[descendant] }
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def estimated_duration(examples)
|
|
133
|
+
examples.inject(0.0) { |total, example| total + run_time_of(example) }
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def run_time_of(example)
|
|
137
|
+
run_times.fetch(example.id, default_run_time)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def default_run_time
|
|
141
|
+
@default_run_time ||= run_times.empty? ? 1.0 : run_times.values.sum / run_times.size
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def run_times
|
|
145
|
+
@run_times ||= load_run_times
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def load_run_times
|
|
149
|
+
paths = [@configuration.example_status_persistence_file_path, @configuration.parallel_run_times_file_path]
|
|
150
|
+
paths.compact.uniq.each_with_object({}) do |path, times|
|
|
151
|
+
ExampleStatusPersister.load_from(path).each do |example|
|
|
152
|
+
run_time = self.class.parse_duration(example[:run_time])
|
|
153
|
+
times[example[:example_id]] = run_time if run_time
|
|
154
|
+
end
|
|
155
|
+
rescue SystemCallError
|
|
156
|
+
next
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
module RSpec
|
|
2
|
+
module Core
|
|
3
|
+
module Parallel
|
|
4
|
+
# Leaves SimpleCov's report to the main process. SimpleCov saves the
|
|
5
|
+
# coverage of every process at exit, and the process it considers the
|
|
6
|
+
# final one merges them, writes the report and checks the minimum
|
|
7
|
+
# coverage. It can't tell the workers from the main process (SimpleCov
|
|
8
|
+
# 1.x even takes the first worker for the final process, because of
|
|
9
|
+
# its `TEST_ENV_NUMBER`), so workers finishing early checked the
|
|
10
|
+
# coverage collected so far and printed false "below minimum coverage"
|
|
11
|
+
# warnings. The main process exits after all the workers.
|
|
12
|
+
#
|
|
13
|
+
# @private
|
|
14
|
+
module SimpleCovIntegration
|
|
15
|
+
# @private
|
|
16
|
+
module NotFinalResultProcess
|
|
17
|
+
def final_result_process?
|
|
18
|
+
false
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.after_fork
|
|
23
|
+
return unless defined?(::SimpleCov) && ::SimpleCov.respond_to?(:final_result_process?)
|
|
24
|
+
|
|
25
|
+
::SimpleCov.singleton_class.__send__(:prepend, NotFinalResultProcess)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|