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.
@@ -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
- 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