crspec 0.1.2 → 0.1.3
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 +4 -4
- data/CHANGELOG.md +86 -0
- data/README.md +1 -1
- data/lib/crspec/cli.rb +86 -5
- data/lib/crspec/configuration.rb +62 -9
- data/lib/crspec/dsl.rb +66 -2
- data/lib/crspec/example.rb +60 -6
- data/lib/crspec/example_group.rb +363 -63
- data/lib/crspec/execution_context.rb +10 -6
- data/lib/crspec/expectations.rb +62 -2
- data/lib/crspec/file_fixtures.rb +18 -0
- data/lib/crspec/formatters/progress_formatter.rb +37 -14
- data/lib/crspec/matchers.rb +642 -0
- data/lib/crspec/mock/argument_matchers.rb +157 -0
- data/lib/crspec/mock/double.rb +246 -13
- data/lib/crspec/mock/interceptor.rb +32 -9
- data/lib/crspec/mock/space.rb +14 -0
- data/lib/crspec/process_runner.rb +332 -0
- data/lib/crspec/rails/assets_shim.rb +33 -0
- data/lib/crspec/rails/database_isolation.rb +179 -8
- data/lib/crspec/rails/parallel.rb +29 -29
- data/lib/crspec/rails/request_helpers.rb +70 -14
- data/lib/crspec/rails/system_server.rb +7 -0
- data/lib/crspec/rails/warden_shim.rb +18 -0
- data/lib/crspec/runner.rb +143 -29
- data/lib/crspec/shared_examples.rb +70 -0
- data/lib/crspec/status_persistence.rb +46 -0
- data/lib/crspec/transpiler/cli.rb +106 -15
- data/lib/crspec/transpiler/rewriter.rb +179 -25
- data/lib/crspec/version.rb +1 -1
- data/lib/crspec.rb +6 -0
- data/lib/rspec/core.rb +2 -0
- data/lib/rspec/expectations.rb +2 -0
- data/lib/rspec/mocks.rb +2 -0
- data/lib/rspec/rails.rb +2 -0
- data/lib/rspec.rb +2 -0
- metadata +27 -1
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "etc"
|
|
4
|
+
require_relative "status_persistence"
|
|
5
|
+
|
|
6
|
+
module Crspec
|
|
7
|
+
# Process tier for true multi-core execution (--processes P). The parent
|
|
8
|
+
# loads specs once (copy-on-write memory), forks P children, and shards
|
|
9
|
+
# examples across them using persisted timings (bin-packing,
|
|
10
|
+
# slowest-first; round-robin on first run). Each child runs the existing
|
|
11
|
+
# N-thread x M-fiber Runner on its shard and streams marshalled result
|
|
12
|
+
# structs back over a pipe (example blocks cannot cross process
|
|
13
|
+
# boundaries). Fail-fast propagates via SIGTERM.
|
|
14
|
+
class ProcessRunner
|
|
15
|
+
Result = Struct.new(:persistence_key, :description, :status, :error_class,
|
|
16
|
+
:error_message, :error_backtrace, :execution_time)
|
|
17
|
+
|
|
18
|
+
attr_reader :passed_examples, :failed_examples, :pending_examples, :total_duration
|
|
19
|
+
|
|
20
|
+
def initialize(processes:, concurrency: Etc.nprocessors, fibers: 1, formatter: nil,
|
|
21
|
+
fail_fast: false, seed: nil, only_failures: false, persistence_path: nil)
|
|
22
|
+
@processes = processes == :auto ? physical_core_count : processes
|
|
23
|
+
@concurrency = concurrency
|
|
24
|
+
@fibers = fibers
|
|
25
|
+
@formatter = formatter || Formatters::ProgressFormatter.new
|
|
26
|
+
@fail_fast = fail_fast == true ? 1 : fail_fast
|
|
27
|
+
@seed = seed
|
|
28
|
+
@only_failures = only_failures
|
|
29
|
+
@persistence_path = persistence_path
|
|
30
|
+
@persistence = StatusPersistence.new(persistence_path)
|
|
31
|
+
@passed_examples = []
|
|
32
|
+
@failed_examples = []
|
|
33
|
+
@pending_examples = []
|
|
34
|
+
@total_duration = 0
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def run(example_groups)
|
|
38
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
39
|
+
@formatter.start
|
|
40
|
+
|
|
41
|
+
example_groups.each(&:finalize!)
|
|
42
|
+
examples = []
|
|
43
|
+
example_groups.each { |group| collect_examples(group, examples) }
|
|
44
|
+
|
|
45
|
+
previous = @persistence.load
|
|
46
|
+
if @only_failures
|
|
47
|
+
examples = examples.select do |ex|
|
|
48
|
+
entry = previous[ex.persistence_key]
|
|
49
|
+
entry.nil? || entry["status"] == "failed"
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
shards = shard_examples(examples, previous)
|
|
54
|
+
|
|
55
|
+
Process.warmup if Process.respond_to?(:warmup)
|
|
56
|
+
|
|
57
|
+
children = shards.each_with_index.filter_map do |shard, index|
|
|
58
|
+
next if shard.empty?
|
|
59
|
+
|
|
60
|
+
spawn_child(shard, index + 1, example_groups)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
failure_total = 0
|
|
64
|
+
aborted = false
|
|
65
|
+
readers = children.to_h { |c| [c[:reader], c] }
|
|
66
|
+
|
|
67
|
+
until readers.empty?
|
|
68
|
+
ready, = IO.select(readers.keys)
|
|
69
|
+
ready.each do |io|
|
|
70
|
+
child = readers[io]
|
|
71
|
+
result = read_result(io)
|
|
72
|
+
if result.nil?
|
|
73
|
+
readers.delete(io)
|
|
74
|
+
io.close
|
|
75
|
+
next
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
record(result)
|
|
79
|
+
next unless result.status == "failed" && @fail_fast
|
|
80
|
+
|
|
81
|
+
failure_total += 1
|
|
82
|
+
next if aborted || failure_total < @fail_fast
|
|
83
|
+
|
|
84
|
+
aborted = true
|
|
85
|
+
children.each do |c|
|
|
86
|
+
Process.kill("TERM", c[:pid])
|
|
87
|
+
rescue Errno::ESRCH
|
|
88
|
+
nil
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
children.each do |c|
|
|
94
|
+
Process.wait(c[:pid])
|
|
95
|
+
rescue Errno::ECHILD
|
|
96
|
+
nil
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
@total_duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
|
100
|
+
persist_results(previous)
|
|
101
|
+
@formatter.finish
|
|
102
|
+
self
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def success?
|
|
106
|
+
@failed_examples.empty?
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
def collect_examples(group, acc)
|
|
112
|
+
acc.concat(group.examples)
|
|
113
|
+
group.children.each { |child| collect_examples(child, acc) }
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Longest-processing-time bin packing: sort slowest-first, assign each
|
|
117
|
+
# example to the least-loaded shard. Round-robin when no timings exist.
|
|
118
|
+
def shard_examples(examples, previous)
|
|
119
|
+
shards = Array.new(@processes) { [] }
|
|
120
|
+
if previous.empty?
|
|
121
|
+
examples.each_with_index { |ex, i| shards[i % @processes] << ex }
|
|
122
|
+
else
|
|
123
|
+
loads = Array.new(@processes, 0.0)
|
|
124
|
+
sorted = examples.sort_by do |ex|
|
|
125
|
+
entry = previous[ex.persistence_key]
|
|
126
|
+
-(entry ? entry["run_time"].to_f : Float::INFINITY)
|
|
127
|
+
end
|
|
128
|
+
sorted.each do |ex|
|
|
129
|
+
idx = loads.each_with_index.min_by { |load, _| load }.last
|
|
130
|
+
shards[idx] << ex
|
|
131
|
+
entry = previous[ex.persistence_key]
|
|
132
|
+
loads[idx] += entry ? entry["run_time"].to_f : 0.1
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
shards
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def spawn_child(shard, process_number, example_groups)
|
|
139
|
+
reader, writer = IO.pipe
|
|
140
|
+
reader.binmode
|
|
141
|
+
writer.binmode
|
|
142
|
+
|
|
143
|
+
pid = fork do
|
|
144
|
+
reader.close
|
|
145
|
+
setup_child_environment(process_number)
|
|
146
|
+
|
|
147
|
+
keys = shard.map(&:persistence_key).to_h { |k| [k, true] }
|
|
148
|
+
formatter = ChildFormatter.new(writer)
|
|
149
|
+
runner = Runner.new(concurrency: @concurrency, fibers: @fibers,
|
|
150
|
+
formatter: formatter, seed: @seed)
|
|
151
|
+
filtered = FilteredGroups.wrap(example_groups, keys)
|
|
152
|
+
runner.run(filtered)
|
|
153
|
+
writer.close
|
|
154
|
+
exit!(runner.success? ? 0 : 1)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
writer.close
|
|
158
|
+
{ pid: pid, reader: reader }
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Per-process databases reuse the Rails parallel-testing convention
|
|
162
|
+
# (database suffixed _N for process N > 1). Set once, pre-thread, where
|
|
163
|
+
# ENV mutation is safe.
|
|
164
|
+
def setup_child_environment(process_number)
|
|
165
|
+
env_num = process_number == 1 ? "" : process_number.to_s
|
|
166
|
+
ENV["TEST_ENV_NUMBER"] = env_num
|
|
167
|
+
ENV["PARALLEL_WORKERS"] = @processes.to_s
|
|
168
|
+
|
|
169
|
+
return if process_number == 1
|
|
170
|
+
return unless defined?(ActiveRecord::Base) && ActiveRecord::Base.respond_to?(:connection_db_config)
|
|
171
|
+
|
|
172
|
+
# Rails' own parallel-testing helper creates the db_N database and
|
|
173
|
+
# loads the schema, honouring TEST_ENV_NUMBER set above.
|
|
174
|
+
if defined?(ActiveRecord::TestDatabases)
|
|
175
|
+
begin
|
|
176
|
+
ActiveRecord::TestDatabases.create_and_load_schema(process_number, env_name: ::Rails.env)
|
|
177
|
+
return
|
|
178
|
+
rescue StandardError
|
|
179
|
+
nil
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
config = begin
|
|
184
|
+
ActiveRecord::Base.connection_db_config
|
|
185
|
+
rescue StandardError
|
|
186
|
+
nil
|
|
187
|
+
end
|
|
188
|
+
return unless config
|
|
189
|
+
|
|
190
|
+
db_name = "#{config.database}_#{process_number}"
|
|
191
|
+
begin
|
|
192
|
+
ActiveRecord::Base.establish_connection(config.configuration_hash.merge(database: db_name))
|
|
193
|
+
rescue StandardError
|
|
194
|
+
nil
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def read_result(io)
|
|
199
|
+
header = io.read(4)
|
|
200
|
+
return nil if header.nil? || header.bytesize < 4
|
|
201
|
+
|
|
202
|
+
length = header.unpack1("N")
|
|
203
|
+
payload = io.read(length)
|
|
204
|
+
return nil if payload.nil? || payload.bytesize < length
|
|
205
|
+
|
|
206
|
+
Marshal.load(payload)
|
|
207
|
+
rescue EOFError, IOError
|
|
208
|
+
nil
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def record(result)
|
|
212
|
+
case result.status
|
|
213
|
+
when "passed"
|
|
214
|
+
@passed_examples << result
|
|
215
|
+
@formatter.example_passed(result)
|
|
216
|
+
when "pending"
|
|
217
|
+
@pending_examples << result
|
|
218
|
+
@formatter.example_pending(result)
|
|
219
|
+
else
|
|
220
|
+
@failed_examples << result
|
|
221
|
+
@formatter.example_failed(result)
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def persist_results(previous)
|
|
226
|
+
(@passed_examples + @failed_examples).each do |result|
|
|
227
|
+
previous[result.persistence_key] = {
|
|
228
|
+
"status" => result.status,
|
|
229
|
+
"run_time" => result.execution_time.to_f.round(6)
|
|
230
|
+
}
|
|
231
|
+
end
|
|
232
|
+
path = @persistence_path
|
|
233
|
+
return unless path
|
|
234
|
+
|
|
235
|
+
dir = File.dirname(path)
|
|
236
|
+
FileUtils.mkdir_p(dir) unless Dir.exist?(dir)
|
|
237
|
+
File.write(path, JSON.pretty_generate(previous))
|
|
238
|
+
rescue SystemCallError
|
|
239
|
+
nil
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def physical_core_count
|
|
243
|
+
if RUBY_PLATFORM.include?("darwin")
|
|
244
|
+
count = `sysctl -n hw.physicalcpu 2>/dev/null`.to_i
|
|
245
|
+
return count if count.positive?
|
|
246
|
+
elsif File.readable?("/proc/cpuinfo")
|
|
247
|
+
cores = File.read("/proc/cpuinfo").scan(/^core id\s*:\s*(\d+)/).uniq.size
|
|
248
|
+
return cores if cores.positive?
|
|
249
|
+
end
|
|
250
|
+
Etc.nprocessors
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Streams marshalled Result structs to the parent as examples finish.
|
|
254
|
+
class ChildFormatter
|
|
255
|
+
def initialize(writer)
|
|
256
|
+
@writer = writer
|
|
257
|
+
@mutex = Mutex.new
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def example_passed(example)
|
|
261
|
+
emit(example, "passed")
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def example_failed(example)
|
|
265
|
+
emit(example, "failed")
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def example_pending(example)
|
|
269
|
+
emit(example, "pending")
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def start; end
|
|
273
|
+
def finish; end
|
|
274
|
+
|
|
275
|
+
private
|
|
276
|
+
|
|
277
|
+
def emit(example, status)
|
|
278
|
+
error = example.respond_to?(:error) ? example.error : nil
|
|
279
|
+
result = Result.new(
|
|
280
|
+
example.persistence_key,
|
|
281
|
+
example.description.to_s,
|
|
282
|
+
status,
|
|
283
|
+
error&.class&.name,
|
|
284
|
+
error&.message,
|
|
285
|
+
error&.backtrace&.first(10),
|
|
286
|
+
example.respond_to?(:execution_time) ? example.execution_time : 0
|
|
287
|
+
)
|
|
288
|
+
payload = Marshal.dump(result)
|
|
289
|
+
@mutex.synchronize do
|
|
290
|
+
@writer.write([payload.bytesize].pack("N"))
|
|
291
|
+
@writer.write(payload)
|
|
292
|
+
@writer.flush
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# Proxy groups that expose only the examples assigned to this shard.
|
|
298
|
+
module FilteredGroups
|
|
299
|
+
def self.wrap(groups, keys)
|
|
300
|
+
groups.map { |g| GroupProxy.new(g, keys) }
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
class GroupProxy
|
|
304
|
+
def initialize(group, keys)
|
|
305
|
+
@group = group
|
|
306
|
+
@keys = keys
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def finalize!
|
|
310
|
+
@group.finalize!
|
|
311
|
+
self
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def examples
|
|
315
|
+
@group.examples.select { |ex| @keys[ex.persistence_key] }
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def children
|
|
319
|
+
@group.children.map { |c| GroupProxy.new(c, @keys) }
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def method_missing(name, *args, &block)
|
|
323
|
+
@group.send(name, *args, &block)
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def respond_to_missing?(name, include_private = false)
|
|
327
|
+
@group.respond_to?(name, include_private) || super
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# In many Rails controller/request specs the asset pipeline is not the
|
|
4
|
+
# subject under test. When running under crspec, we provide a small shim
|
|
5
|
+
# around Sprockets helpers so that missing compiled assets do not cause
|
|
6
|
+
# hard failures.
|
|
7
|
+
|
|
8
|
+
module Crspec
|
|
9
|
+
module Rails
|
|
10
|
+
module AssetsShim
|
|
11
|
+
module ComputeAssetPathFallback
|
|
12
|
+
def compute_asset_path(path, options = {})
|
|
13
|
+
super
|
|
14
|
+
rescue StandardError
|
|
15
|
+
path.to_s
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.install!
|
|
20
|
+
return if @installed
|
|
21
|
+
return unless defined?(::Rails) && ::Rails.env.test?
|
|
22
|
+
return unless defined?(::Sprockets::Rails::Helper)
|
|
23
|
+
|
|
24
|
+
::Sprockets::Rails::Helper.prepend(ComputeAssetPathFallback)
|
|
25
|
+
@installed = true
|
|
26
|
+
rescue StandardError
|
|
27
|
+
nil
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
Crspec::Rails::AssetsShim.install!
|
|
@@ -3,16 +3,187 @@
|
|
|
3
3
|
module Crspec
|
|
4
4
|
module Rails
|
|
5
5
|
class DatabaseIsolation
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
6
|
+
LEASES_KEY = :crspec_db_leases
|
|
7
|
+
|
|
8
|
+
class << self
|
|
9
|
+
def wrap_example(example)
|
|
10
|
+
return example.execute! unless active_record_ready?
|
|
11
|
+
|
|
12
|
+
pools = writing_pools
|
|
13
|
+
return example.execute! if pools.empty?
|
|
14
|
+
|
|
15
|
+
if pools.any? { |pool| sqlite_pool?(pool) }
|
|
16
|
+
serialized_example(pools) { example.execute! }
|
|
17
|
+
else
|
|
18
|
+
savepoint_example { example.execute! }
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def finish_worker
|
|
23
|
+
leases = Fiber[LEASES_KEY]
|
|
24
|
+
return unless leases
|
|
25
|
+
|
|
26
|
+
Fiber[LEASES_KEY] = nil
|
|
27
|
+
leases.each do |pool, conn|
|
|
28
|
+
begin
|
|
29
|
+
conn.rollback_transaction while conn.open_transactions.positive?
|
|
30
|
+
rescue StandardError
|
|
31
|
+
nil
|
|
12
32
|
end
|
|
33
|
+
release(pool, conn)
|
|
13
34
|
end
|
|
14
|
-
|
|
15
|
-
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def handoff_connection(pool)
|
|
38
|
+
Fiber[LEASES_KEY]&.[](pool)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
# PG/MySQL: each worker keeps a leased connection with a root
|
|
44
|
+
# non-joinable transaction; every example runs inside a nested
|
|
45
|
+
# transaction (SAVEPOINT) rolled back afterwards.
|
|
46
|
+
def savepoint_example
|
|
47
|
+
leases = ensure_worker_leases!
|
|
48
|
+
return yield if leases.empty?
|
|
49
|
+
|
|
50
|
+
depths = {}
|
|
51
|
+
leases.each do |pool, conn|
|
|
52
|
+
depths[pool] = conn.open_transactions
|
|
53
|
+
conn.begin_transaction(joinable: false)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
begin
|
|
57
|
+
yield
|
|
58
|
+
ensure
|
|
59
|
+
leases.each do |pool, conn|
|
|
60
|
+
base_depth = depths[pool] + 1
|
|
61
|
+
begin
|
|
62
|
+
conn.rollback_transaction while conn.open_transactions >= base_depth
|
|
63
|
+
rescue StandardError
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# SQLite permits a single writer per database file, so write
|
|
71
|
+
# transactions cannot overlap across worker threads. Examples
|
|
72
|
+
# touching SQLite pools are serialized with per-example
|
|
73
|
+
# transactions; use --processes with per-process databases for
|
|
74
|
+
# SQLite concurrency.
|
|
75
|
+
def serialized_example(pools)
|
|
76
|
+
write_mutex.synchronize do
|
|
77
|
+
leases = {}
|
|
78
|
+
pools.each do |pool|
|
|
79
|
+
conn = lease(pool)
|
|
80
|
+
next unless conn
|
|
81
|
+
|
|
82
|
+
conn.begin_transaction(joinable: false)
|
|
83
|
+
leases[pool] = conn
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
previous = Fiber[LEASES_KEY]
|
|
87
|
+
Fiber[LEASES_KEY] = leases
|
|
88
|
+
begin
|
|
89
|
+
yield
|
|
90
|
+
ensure
|
|
91
|
+
Fiber[LEASES_KEY] = previous
|
|
92
|
+
leases.each do |pool, conn|
|
|
93
|
+
begin
|
|
94
|
+
conn.rollback_transaction while conn.open_transactions.positive?
|
|
95
|
+
rescue StandardError
|
|
96
|
+
nil
|
|
97
|
+
end
|
|
98
|
+
release(pool, conn)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def write_mutex
|
|
105
|
+
@write_mutex ||= Mutex.new
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def active_record_ready?
|
|
109
|
+
defined?(ActiveRecord::Base) && ActiveRecord::Base.connected?
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def ensure_worker_leases!
|
|
113
|
+
Fiber[LEASES_KEY] ||= begin
|
|
114
|
+
install_handoff!
|
|
115
|
+
writing_pools.each_with_object({}) do |pool, leases|
|
|
116
|
+
conn = lease(pool)
|
|
117
|
+
next unless conn
|
|
118
|
+
|
|
119
|
+
conn.begin_transaction(joinable: false)
|
|
120
|
+
leases[pool] = conn
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def writing_pools
|
|
126
|
+
ActiveRecord::Base.connection_handler.connection_pool_list(:writing)
|
|
127
|
+
rescue StandardError
|
|
128
|
+
[]
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def sqlite_pool?(pool)
|
|
132
|
+
adapter = pool.db_config.adapter.to_s
|
|
133
|
+
adapter.match?(/sqlite/i)
|
|
134
|
+
rescue StandardError
|
|
135
|
+
false
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def lease(pool)
|
|
139
|
+
install_handoff!
|
|
140
|
+
if pool.respond_to?(:lease_connection)
|
|
141
|
+
pool.lease_connection
|
|
142
|
+
else
|
|
143
|
+
pool.checkout
|
|
144
|
+
end
|
|
145
|
+
rescue StandardError
|
|
146
|
+
nil
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def release(pool, conn)
|
|
150
|
+
if pool.respond_to?(:release_connection)
|
|
151
|
+
pool.release_connection
|
|
152
|
+
else
|
|
153
|
+
pool.checkin(conn)
|
|
154
|
+
end
|
|
155
|
+
rescue StandardError
|
|
156
|
+
nil
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def install_handoff!
|
|
160
|
+
return if @handoff_installed
|
|
161
|
+
return unless defined?(ActiveRecord::ConnectionAdapters::ConnectionPool)
|
|
162
|
+
|
|
163
|
+
ActiveRecord::ConnectionAdapters::ConnectionPool.prepend(ConnectionHandoff)
|
|
164
|
+
@handoff_installed = true
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
module ConnectionHandoff
|
|
169
|
+
def lease_connection(*args)
|
|
170
|
+
DatabaseIsolation.handoff_connection(self) || super
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def connection(*args)
|
|
174
|
+
DatabaseIsolation.handoff_connection(self) || super
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def release_connection(*args)
|
|
178
|
+
return if DatabaseIsolation.handoff_connection(self)
|
|
179
|
+
|
|
180
|
+
super
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def checkin(conn, *args)
|
|
184
|
+
return if DatabaseIsolation.handoff_connection(self) == conn
|
|
185
|
+
|
|
186
|
+
super
|
|
16
187
|
end
|
|
17
188
|
end
|
|
18
189
|
end
|
|
@@ -1,14 +1,25 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "etc"
|
|
4
|
+
require_relative "assets_shim"
|
|
5
|
+
require_relative "warden_shim"
|
|
4
6
|
|
|
5
7
|
module Crspec
|
|
6
8
|
module Rails
|
|
7
9
|
module Parallel
|
|
10
|
+
WORKER_NUMBER_KEY = :crspec_worker_number
|
|
11
|
+
|
|
8
12
|
class << self
|
|
9
13
|
attr_accessor :worker_count, :setup_blocks, :teardown_blocks, :enabled
|
|
10
14
|
|
|
11
15
|
def parallelize(workers: :number_of_processors, &block)
|
|
16
|
+
# For Rails test environments it is often safer to run without
|
|
17
|
+
# process-level parallelism unless the application explicitly opts
|
|
18
|
+
# in. This avoids issues with shared transactional fixtures and
|
|
19
|
+
# global state.
|
|
20
|
+
if defined?(::Rails) && ::Rails.respond_to?(:env) && ::Rails.env.test?
|
|
21
|
+
workers = 1 if workers == :number_of_processors
|
|
22
|
+
end
|
|
12
23
|
count = case workers
|
|
13
24
|
when :number_of_processors
|
|
14
25
|
Etc.nprocessors
|
|
@@ -41,25 +52,35 @@ module Crspec
|
|
|
41
52
|
@teardown_blocks << block
|
|
42
53
|
end
|
|
43
54
|
|
|
55
|
+
# Worker identity lives in Fiber Storage (inherited by fibers
|
|
56
|
+
# spawned within the worker), never in ENV: mutating
|
|
57
|
+
# ENV["TEST_ENV_NUMBER"] from worker threads is a process-wide
|
|
58
|
+
# race. Per-worker databases move to the process tier
|
|
59
|
+
# (--processes), where the ENV convention is safe to set once
|
|
60
|
+
# per child process before any threads start.
|
|
44
61
|
def setup_worker(worker_number)
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
62
|
+
Fiber[WORKER_NUMBER_KEY] = worker_number
|
|
63
|
+
@setup_blocks&.each { |b| b.call(worker_number) }
|
|
64
|
+
end
|
|
48
65
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
66
|
+
def current_worker_number
|
|
67
|
+
Fiber[WORKER_NUMBER_KEY]
|
|
68
|
+
end
|
|
52
69
|
|
|
53
|
-
|
|
70
|
+
def test_env_number(worker_number = current_worker_number)
|
|
71
|
+
return "" if worker_number.nil? || worker_number == 1
|
|
72
|
+
|
|
73
|
+
worker_number.to_s
|
|
54
74
|
end
|
|
55
75
|
|
|
56
76
|
def teardown_worker(worker_number)
|
|
57
77
|
@teardown_blocks&.each { |b| b.call(worker_number) }
|
|
78
|
+
Fiber[WORKER_NUMBER_KEY] = nil
|
|
58
79
|
|
|
59
80
|
return unless defined?(ActiveRecord::Base) && ActiveRecord::Base.respond_to?(:connection_handler)
|
|
60
81
|
|
|
61
82
|
begin
|
|
62
|
-
ActiveRecord::Base.connection_handler.
|
|
83
|
+
ActiveRecord::Base.connection_handler.clear_active_connections!
|
|
63
84
|
rescue StandardError
|
|
64
85
|
nil
|
|
65
86
|
end
|
|
@@ -71,27 +92,6 @@ module Crspec
|
|
|
71
92
|
@setup_blocks = []
|
|
72
93
|
@teardown_blocks = []
|
|
73
94
|
end
|
|
74
|
-
|
|
75
|
-
private
|
|
76
|
-
|
|
77
|
-
def setup_active_record_db(worker_number)
|
|
78
|
-
return if worker_number == 1
|
|
79
|
-
|
|
80
|
-
config = begin
|
|
81
|
-
ActiveRecord::Base.connection_db_config
|
|
82
|
-
rescue StandardError
|
|
83
|
-
nil
|
|
84
|
-
end
|
|
85
|
-
return unless config
|
|
86
|
-
|
|
87
|
-
db_name = "#{config.database}_#{worker_number}"
|
|
88
|
-
new_config = config.configuration_hash.merge(database: db_name)
|
|
89
|
-
begin
|
|
90
|
-
ActiveRecord::Base.establish_connection(new_config)
|
|
91
|
-
rescue StandardError
|
|
92
|
-
nil
|
|
93
|
-
end
|
|
94
|
-
end
|
|
95
95
|
end
|
|
96
96
|
end
|
|
97
97
|
end
|