crspec 0.1.2 → 0.1.5

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.
@@ -0,0 +1,348 @@
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
+ # Forking is only meaningful (and only available) on runtimes with a
21
+ # GVL, i.e. CRuby. On JRuby/TruffleRuby threads already use all cores,
22
+ # so `-c N` is the multi-core tier there.
23
+ def self.fork_supported?
24
+ Process.respond_to?(:fork) && !Process.method(:fork).nil? &&
25
+ RUBY_ENGINE == "ruby"
26
+ end
27
+
28
+ def initialize(processes:, concurrency: Etc.nprocessors, fibers: 1, formatter: nil,
29
+ fail_fast: false, seed: nil, only_failures: false, persistence_path: nil)
30
+ unless self.class.fork_supported?
31
+ raise Crspec::Error, <<~MSG
32
+ --processes requires fork, which #{RUBY_ENGINE} does not support.
33
+ On #{RUBY_ENGINE} threads are not limited by a GVL, so worker
34
+ threads already use all cores: use -c/--concurrency instead
35
+ (e.g. `crspec -c #{Etc.nprocessors}`).
36
+ MSG
37
+ end
38
+
39
+ @processes = processes == :auto ? physical_core_count : processes
40
+ @concurrency = concurrency
41
+ @fibers = fibers
42
+ @formatter = formatter || Formatters::ProgressFormatter.new
43
+ @fail_fast = fail_fast == true ? 1 : fail_fast
44
+ @seed = seed
45
+ @only_failures = only_failures
46
+ @persistence_path = persistence_path
47
+ @persistence = StatusPersistence.new(persistence_path)
48
+ @passed_examples = []
49
+ @failed_examples = []
50
+ @pending_examples = []
51
+ @total_duration = 0
52
+ end
53
+
54
+ def run(example_groups)
55
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
56
+ @formatter.start
57
+
58
+ example_groups.each(&:finalize!)
59
+ examples = []
60
+ example_groups.each { |group| collect_examples(group, examples) }
61
+
62
+ previous = @persistence.load
63
+ if @only_failures
64
+ examples = examples.select do |ex|
65
+ entry = previous[ex.persistence_key]
66
+ entry.nil? || entry["status"] == "failed"
67
+ end
68
+ end
69
+
70
+ shards = shard_examples(examples, previous)
71
+
72
+ Process.warmup if Process.respond_to?(:warmup)
73
+
74
+ children = shards.each_with_index.filter_map do |shard, index|
75
+ next if shard.empty?
76
+
77
+ spawn_child(shard, index + 1, example_groups)
78
+ end
79
+
80
+ failure_total = 0
81
+ aborted = false
82
+ readers = children.to_h { |c| [c[:reader], c] }
83
+
84
+ until readers.empty?
85
+ ready, = IO.select(readers.keys)
86
+ ready.each do |io|
87
+ result = read_result(io)
88
+ if result.nil?
89
+ readers.delete(io)
90
+ io.close
91
+ next
92
+ end
93
+
94
+ record(result)
95
+ next unless result.status == "failed" && @fail_fast
96
+
97
+ failure_total += 1
98
+ next if aborted || failure_total < @fail_fast
99
+
100
+ aborted = true
101
+ children.each do |c|
102
+ Process.kill("TERM", c[:pid])
103
+ rescue Errno::ESRCH
104
+ nil
105
+ end
106
+ end
107
+ end
108
+
109
+ children.each do |c|
110
+ Process.wait(c[:pid])
111
+ rescue Errno::ECHILD
112
+ nil
113
+ end
114
+
115
+ @total_duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
116
+ persist_results(previous)
117
+ @formatter.finish
118
+ self
119
+ end
120
+
121
+ def success?
122
+ @failed_examples.empty?
123
+ end
124
+
125
+ private
126
+
127
+ def collect_examples(group, acc)
128
+ acc.concat(group.examples)
129
+ group.children.each { |child| collect_examples(child, acc) }
130
+ end
131
+
132
+ # Longest-processing-time bin packing: sort slowest-first, assign each
133
+ # example to the least-loaded shard. Round-robin when no timings exist.
134
+ def shard_examples(examples, previous)
135
+ shards = Array.new(@processes) { [] }
136
+ if previous.empty?
137
+ examples.each_with_index { |ex, i| shards[i % @processes] << ex }
138
+ else
139
+ loads = Array.new(@processes, 0.0)
140
+ sorted = examples.sort_by do |ex|
141
+ entry = previous[ex.persistence_key]
142
+ -(entry ? entry["run_time"].to_f : Float::INFINITY)
143
+ end
144
+ sorted.each do |ex|
145
+ idx = loads.each_with_index.min_by { |load, _| load }.last
146
+ shards[idx] << ex
147
+ entry = previous[ex.persistence_key]
148
+ loads[idx] += entry ? entry["run_time"].to_f : 0.1
149
+ end
150
+ end
151
+ shards
152
+ end
153
+
154
+ def spawn_child(shard, process_number, example_groups)
155
+ reader, writer = IO.pipe
156
+ reader.binmode
157
+ writer.binmode
158
+
159
+ pid = fork do
160
+ reader.close
161
+ setup_child_environment(process_number)
162
+
163
+ keys = shard.map(&:persistence_key).to_h { |k| [k, true] }
164
+ formatter = ChildFormatter.new(writer)
165
+ runner = Runner.new(concurrency: @concurrency, fibers: @fibers,
166
+ formatter: formatter, seed: @seed)
167
+ filtered = FilteredGroups.wrap(example_groups, keys)
168
+ runner.run(filtered)
169
+ writer.close
170
+ exit!(runner.success? ? 0 : 1)
171
+ end
172
+
173
+ writer.close
174
+ { pid: pid, reader: reader }
175
+ end
176
+
177
+ # Per-process databases reuse the Rails parallel-testing convention
178
+ # (database suffixed _N for process N > 1). Set once, pre-thread, where
179
+ # ENV mutation is safe.
180
+ def setup_child_environment(process_number)
181
+ env_num = process_number == 1 ? "" : process_number.to_s
182
+ ENV["TEST_ENV_NUMBER"] = env_num
183
+ ENV["PARALLEL_WORKERS"] = @processes.to_s
184
+
185
+ return if process_number == 1
186
+ return unless defined?(ActiveRecord::Base) && ActiveRecord::Base.respond_to?(:connection_db_config)
187
+
188
+ # Rails' own parallel-testing helper creates the db_N database and
189
+ # loads the schema, honouring TEST_ENV_NUMBER set above.
190
+ if defined?(ActiveRecord::TestDatabases)
191
+ begin
192
+ ActiveRecord::TestDatabases.create_and_load_schema(process_number, env_name: ::Rails.env)
193
+ return
194
+ rescue StandardError
195
+ nil
196
+ end
197
+ end
198
+
199
+ config = begin
200
+ ActiveRecord::Base.connection_db_config
201
+ rescue StandardError
202
+ nil
203
+ end
204
+ return unless config
205
+
206
+ db_name = "#{config.database}_#{process_number}"
207
+ begin
208
+ ActiveRecord::Base.establish_connection(config.configuration_hash.merge(database: db_name))
209
+ rescue StandardError
210
+ nil
211
+ end
212
+ end
213
+
214
+ def read_result(io)
215
+ header = io.read(4)
216
+ return nil if header.nil? || header.bytesize < 4
217
+
218
+ length = header.unpack1("N")
219
+ payload = io.read(length)
220
+ return nil if payload.nil? || payload.bytesize < length
221
+
222
+ Marshal.load(payload)
223
+ rescue EOFError, IOError
224
+ nil
225
+ end
226
+
227
+ def record(result)
228
+ case result.status
229
+ when "passed"
230
+ @passed_examples << result
231
+ @formatter.example_passed(result)
232
+ when "pending"
233
+ @pending_examples << result
234
+ @formatter.example_pending(result)
235
+ else
236
+ @failed_examples << result
237
+ @formatter.example_failed(result)
238
+ end
239
+ end
240
+
241
+ def persist_results(previous)
242
+ (@passed_examples + @failed_examples).each do |result|
243
+ previous[result.persistence_key] = {
244
+ "status" => result.status,
245
+ "run_time" => result.execution_time.to_f.round(6)
246
+ }
247
+ end
248
+ path = @persistence_path
249
+ return unless path
250
+
251
+ dir = File.dirname(path)
252
+ FileUtils.mkdir_p(dir) unless Dir.exist?(dir)
253
+ File.write(path, JSON.pretty_generate(previous))
254
+ rescue SystemCallError
255
+ nil
256
+ end
257
+
258
+ def physical_core_count
259
+ if RUBY_PLATFORM.include?("darwin")
260
+ count = `sysctl -n hw.physicalcpu 2>/dev/null`.to_i
261
+ return count if count.positive?
262
+ elsif File.readable?("/proc/cpuinfo")
263
+ cores = File.read("/proc/cpuinfo").scan(/^core id\s*:\s*(\d+)/).uniq.size
264
+ return cores if cores.positive?
265
+ end
266
+ Etc.nprocessors
267
+ end
268
+
269
+ # Streams marshalled Result structs to the parent as examples finish.
270
+ class ChildFormatter
271
+ def initialize(writer)
272
+ @writer = writer
273
+ @mutex = Mutex.new
274
+ end
275
+
276
+ def example_passed(example)
277
+ emit(example, "passed")
278
+ end
279
+
280
+ def example_failed(example)
281
+ emit(example, "failed")
282
+ end
283
+
284
+ def example_pending(example)
285
+ emit(example, "pending")
286
+ end
287
+
288
+ def start; end
289
+ def finish; end
290
+
291
+ private
292
+
293
+ def emit(example, status)
294
+ error = example.respond_to?(:error) ? example.error : nil
295
+ result = Result.new(
296
+ example.persistence_key,
297
+ example.description.to_s,
298
+ status,
299
+ error&.class&.name,
300
+ error&.message,
301
+ error&.backtrace&.first(10),
302
+ example.respond_to?(:execution_time) ? example.execution_time : 0
303
+ )
304
+ payload = Marshal.dump(result)
305
+ @mutex.synchronize do
306
+ @writer.write([payload.bytesize].pack("N"))
307
+ @writer.write(payload)
308
+ @writer.flush
309
+ end
310
+ end
311
+ end
312
+
313
+ # Proxy groups that expose only the examples assigned to this shard.
314
+ module FilteredGroups
315
+ def self.wrap(groups, keys)
316
+ groups.map { |g| GroupProxy.new(g, keys) }
317
+ end
318
+
319
+ class GroupProxy
320
+ def initialize(group, keys)
321
+ @group = group
322
+ @keys = keys
323
+ end
324
+
325
+ def finalize!
326
+ @group.finalize!
327
+ self
328
+ end
329
+
330
+ def examples
331
+ @group.examples.select { |ex| @keys[ex.persistence_key] }
332
+ end
333
+
334
+ def children
335
+ @group.children.map { |c| GroupProxy.new(c, @keys) }
336
+ end
337
+
338
+ def method_missing(name, *args, &block)
339
+ @group.send(name, *args, &block)
340
+ end
341
+
342
+ def respond_to_missing?(name, include_private = false)
343
+ @group.respond_to?(name, include_private) || super
344
+ end
345
+ end
346
+ end
347
+ end
348
+ 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
- def self.wrap_example(example)
7
- if defined?(ActiveRecord::Base) && ActiveRecord::Base.connected?
8
- ActiveRecord::Base.connection_pool.with_connection do |conn|
9
- conn.transaction(requires_new: true) do
10
- example.execute!
11
- raise ActiveRecord::Rollback
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
- else
15
- example.execute!
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
- env_num = worker_number == 1 ? "" : worker_number.to_s
46
- ENV["TEST_ENV_NUMBER"] = env_num
47
- ENV["PARALLEL_WORKERS"] = (@worker_count || Etc.nprocessors).to_s
62
+ Fiber[WORKER_NUMBER_KEY] = worker_number
63
+ @setup_blocks&.each { |b| b.call(worker_number) }
64
+ end
48
65
 
49
- if defined?(ActiveRecord::Base) && ActiveRecord::Base.respond_to?(:connection_db_config)
50
- setup_active_record_db(worker_number)
51
- end
66
+ def current_worker_number
67
+ Fiber[WORKER_NUMBER_KEY]
68
+ end
52
69
 
53
- @setup_blocks&.each { |b| b.call(worker_number) }
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.clear_all_connections!
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