ci-queue 0.82.0 → 0.83.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.
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+ require 'zlib'
5
+
6
+ module Minitest
7
+ module Queue
8
+ class LazyTestDiscovery
9
+ def initialize(loader:, resolver:)
10
+ @loader = loader
11
+ @resolver = resolver
12
+ end
13
+
14
+ def enumerator(files)
15
+ Enumerator.new do |yielder|
16
+ each_test(files) do |test|
17
+ yielder << test
18
+ end
19
+ end
20
+ end
21
+
22
+ def each_test(files)
23
+ discovery_start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
24
+ total_files = 0
25
+ new_runnable_files = 0
26
+ reopened_files = 0
27
+ reopened_candidates = 0
28
+ reopened_scan_time = 0.0
29
+
30
+ seen = Set.new
31
+ runnables = Minitest::Test.runnables
32
+ known_count = runnables.size
33
+ by_full_name = {}
34
+ by_short_name = Hash.new { |h, k| h[k] = [] }
35
+ index_runnables(runnables, by_full_name, by_short_name)
36
+
37
+ files.each do |file|
38
+ total_files += 1
39
+ file_path = File.expand_path(file)
40
+ begin
41
+ @loader.load_file(file_path)
42
+ rescue CI::Queue::FileLoadError => error
43
+ method_name = "load_file_#{Zlib.crc32(file_path).to_s(16)}"
44
+ class_name = "CIQueue::FileLoadError"
45
+ test_id = "#{class_name}##{method_name}"
46
+ entry = CI::Queue::QueueEntry.format(
47
+ test_id,
48
+ CI::Queue::QueueEntry.encode_load_error(file_path, error),
49
+ )
50
+ yield Minitest::Queue::LazySingleExample.new(
51
+ class_name,
52
+ method_name,
53
+ file_path,
54
+ loader: @loader,
55
+ resolver: @resolver,
56
+ load_error: error,
57
+ queue_entry: entry,
58
+ )
59
+ next
60
+ end
61
+
62
+ runnables = Minitest::Test.runnables
63
+ candidates = []
64
+ if runnables.size > known_count
65
+ new_runnables = runnables[known_count..]
66
+ known_count = runnables.size
67
+ index_runnables(new_runnables, by_full_name, by_short_name)
68
+ candidates.concat(new_runnables)
69
+ new_runnable_files += 1
70
+ else
71
+ # Re-opened classes do not increase runnables size. In that case, map
72
+ # declared class names in the file to known runnables directly.
73
+ reopened_start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
74
+ reopened = reopened_runnables_for_file(file_path, by_full_name, by_short_name)
75
+ reopened_scan_time += Process.clock_gettime(Process::CLOCK_MONOTONIC) - reopened_start
76
+ unless reopened.empty?
77
+ reopened_files += 1
78
+ reopened_candidates += reopened.size
79
+ end
80
+ candidates.concat(reopened)
81
+ end
82
+
83
+ enqueue_discovered_tests(candidates.uniq, file_path, seen) do |test|
84
+ yield test
85
+ end
86
+ end
87
+ ensure
88
+ debug_discovery_profile(
89
+ discovery_start: discovery_start,
90
+ total_files: total_files,
91
+ new_runnable_files: new_runnable_files,
92
+ reopened_files: reopened_files,
93
+ reopened_candidates: reopened_candidates,
94
+ reopened_scan_time: reopened_scan_time,
95
+ )
96
+ end
97
+
98
+ private
99
+
100
+ def reopened_runnables_for_file(file_path, by_full_name, by_short_name)
101
+ declared = declared_class_names(file_path)
102
+ return [] if declared.empty?
103
+
104
+ declared.each_with_object([]) do |name, runnables|
105
+ runnable = by_full_name[name]
106
+ if runnable
107
+ runnables << runnable
108
+ next
109
+ end
110
+
111
+ short_name = name.split('::').last
112
+ runnables.concat(by_short_name[short_name])
113
+ end
114
+ end
115
+
116
+ def index_runnables(runnables, by_full_name, by_short_name)
117
+ runnables.each do |runnable|
118
+ name = runnable.name
119
+ next unless name
120
+
121
+ by_full_name[name] ||= runnable
122
+ short_name = name.split('::').last
123
+ by_short_name[short_name] << runnable
124
+ end
125
+ end
126
+
127
+ def debug_discovery_profile(discovery_start:, total_files:, new_runnable_files:, reopened_files:, reopened_candidates:, reopened_scan_time:)
128
+ return unless CI::Queue.debug?
129
+
130
+ total_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - discovery_start
131
+ puts "[ci-queue][lazy-discovery] files=#{total_files} new_runnable_files=#{new_runnable_files} " \
132
+ "reopened_files=#{reopened_files} reopened_candidates=#{reopened_candidates} " \
133
+ "reopened_scan_time=#{reopened_scan_time.round(2)}s total_time=#{total_time.round(2)}s"
134
+ end
135
+
136
+ def enqueue_discovered_tests(runnables, file_path, seen)
137
+ runnables.each do |runnable|
138
+ runnable.runnable_methods.each do |method_name|
139
+ test_id = "#{runnable.name}##{method_name}"
140
+ next if seen.include?(test_id)
141
+
142
+ seen.add(test_id)
143
+ yield Minitest::Queue::LazySingleExample.new(
144
+ runnable.name,
145
+ method_name,
146
+ file_path,
147
+ loader: @loader,
148
+ resolver: @resolver,
149
+ )
150
+ rescue NameError, NoMethodError
151
+ next
152
+ end
153
+ end
154
+ end
155
+
156
+ def declared_class_names(file_path)
157
+ names = Set.new
158
+ ::File.foreach(file_path) do |line|
159
+ match = line.match(/^\s*class\s+([A-Z]\w*(?:::[A-Z]\w*)*)\b/)
160
+ names.add(match[1]) if match
161
+ end
162
+ names
163
+ rescue SystemCallError
164
+ Set.new
165
+ end
166
+
167
+ end
168
+ end
169
+ end
@@ -46,6 +46,17 @@ module Minitest
46
46
  def result_line
47
47
  "#{super}, #{requeues} requeues"
48
48
  end
49
+
50
+ def location(exception)
51
+ backtrace = exception.backtrace
52
+ return super if backtrace && !backtrace.empty?
53
+
54
+ nested_exception = exception.respond_to?(:error) ? exception.error : nil
55
+ nested_backtrace = nested_exception&.backtrace
56
+ return super(nested_exception) if nested_backtrace && !nested_backtrace.empty?
57
+
58
+ 'unknown'
59
+ end
49
60
  end
50
61
  end
51
62
  end
@@ -5,6 +5,9 @@ class Minitest::Queue::OrderReporter < Minitest::Reporters::BaseReporter
5
5
  def initialize(options = {})
6
6
  @path = options.delete(:path)
7
7
  @file = nil
8
+ @flush_every = Integer(ENV.fetch('CI_QUEUE_ORDER_FLUSH_EVERY', '50'))
9
+ @flush_every = 1 if @flush_every < 1
10
+ @pending = 0
8
11
  super
9
12
  end
10
13
 
@@ -16,10 +19,15 @@ class Minitest::Queue::OrderReporter < Minitest::Reporters::BaseReporter
16
19
  def before_test(test)
17
20
  super
18
21
  file.puts("#{test.class.name}##{test.name}")
19
- file.flush
22
+ @pending += 1
23
+ if @pending >= @flush_every
24
+ file.flush
25
+ @pending = 0
26
+ end
20
27
  end
21
28
 
22
29
  def report
30
+ file.flush
23
31
  file.close
24
32
  end
25
33
 
@@ -29,4 +37,3 @@ class Minitest::Queue::OrderReporter < Minitest::Reporters::BaseReporter
29
37
  @file ||= File.open(@path, 'a+')
30
38
  end
31
39
  end
32
-
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+ require 'minitest/queue/lazy_entry_resolver'
5
+ require 'minitest/queue/lazy_test_discovery'
6
+
7
+ module Minitest
8
+ module Queue
9
+ class QueuePopulationStrategy
10
+ attr_reader :load_tests_duration, :total_files
11
+
12
+ def initialize(queue:, queue_config:, argv:, test_files_file:, ordering_seed:, preresolved_test_list: nil)
13
+ @queue = queue
14
+ @queue_config = queue_config
15
+ @argv = argv
16
+ @test_files_file = test_files_file
17
+ @ordering_seed = ordering_seed
18
+ @preresolved_test_list = preresolved_test_list
19
+ end
20
+
21
+ def load_and_populate!
22
+ load_tests
23
+ populate_queue
24
+ end
25
+
26
+ private
27
+
28
+ attr_reader :queue, :queue_config, :argv, :test_files_file, :ordering_seed, :preresolved_test_list
29
+
30
+ def populate_queue
31
+ if preresolved_test_list && queue.respond_to?(:stream_populate)
32
+ configure_lazy_queue
33
+ queue.stream_populate(preresolved_entry_enumerator, random: ordering_seed, batch_size: queue_config.lazy_load_stream_batch_size)
34
+ elsif queue_config.lazy_load && queue.respond_to?(:stream_populate)
35
+ configure_lazy_queue
36
+ queue.stream_populate(lazy_test_enumerator, random: ordering_seed, batch_size: queue_config.lazy_load_stream_batch_size)
37
+ else
38
+ queue.populate(Minitest.loaded_tests, random: ordering_seed)
39
+ end
40
+ end
41
+
42
+ def load_tests
43
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
44
+ if preresolved_test_list || queue_config.lazy_load
45
+ # In preresolved or lazy-load mode, test files are loaded on-demand by the entry resolver.
46
+ # Load test helpers (e.g., test/test_helper.rb via CI_QUEUE_LAZY_LOAD_TEST_HELPERS)
47
+ # to boot the app for all workers.
48
+ queue_config.lazy_load_test_helper_paths.each do |helper_path|
49
+ require File.expand_path(helper_path)
50
+ end
51
+ else
52
+ test_file_list.sort.each do |file_path|
53
+ require File.expand_path(file_path)
54
+ end
55
+ end
56
+ ensure
57
+ @load_tests_duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
58
+ @total_files = begin
59
+ preresolved_test_list ? nil : test_file_list.size
60
+ rescue StandardError
61
+ nil
62
+ end
63
+ end
64
+
65
+ def configure_lazy_queue
66
+ return unless queue.respond_to?(:entry_resolver=)
67
+
68
+ queue.entry_resolver = lazy_entry_resolver
69
+ end
70
+
71
+ def lazy_entry_resolver
72
+ loader = queue.respond_to?(:file_loader) ? queue.file_loader : CI::Queue::FileLoader.new
73
+ resolver = CI::Queue::ClassResolver
74
+ Minitest::Queue::LazyEntryResolver.new(loader: loader, resolver: resolver)
75
+ end
76
+
77
+ # Reads a pre-resolved test list file (one entry per line: "TestId|file/path.rb")
78
+ # and yields entries in the internal tab-delimited QueueEntry format.
79
+ #
80
+ # When --test-files is also provided, those files act as a reconcile set:
81
+ # preresolved entries whose file path matches are skipped (they may be stale),
82
+ # and the files are lazily discovered to enqueue their current tests.
83
+ # This runs only on the leader (stream_populate is leader-only), so discovery
84
+ # happens exactly once per build.
85
+ def preresolved_entry_enumerator
86
+ override_files = reconcile_file_set
87
+
88
+ Enumerator.new do |yielder|
89
+ preresolved_kept = 0
90
+ preresolved_skipped = 0
91
+
92
+ File.foreach(preresolved_test_list) do |line|
93
+ line = line.chomp
94
+ next if line.strip.empty?
95
+
96
+ # Split on the LAST pipe — test method names can contain '|'
97
+ # (e.g., regex patterns, boolean conditions) but file paths never do.
98
+ test_id, _, file_path = line.rpartition('|')
99
+ if test_id.empty?
100
+ # No pipe found — treat the whole line as a test ID with no file path
101
+ test_id = file_path
102
+ file_path = nil
103
+ end
104
+
105
+ # Skip entries for files that will be re-discovered via --test-files
106
+ if file_path && override_files.include?(file_path)
107
+ preresolved_skipped += 1
108
+ next
109
+ end
110
+
111
+ preresolved_kept += 1
112
+ yielder << CI::Queue::QueueEntry.format(test_id, file_path)
113
+ end
114
+
115
+ if CI::Queue.debug?
116
+ puts "[ci-queue][preresolved] kept=#{preresolved_kept} skipped=#{preresolved_skipped} " \
117
+ "reconcile_files=#{override_files.size}"
118
+ end
119
+
120
+ # Lazily discover current tests for each reconciled file
121
+ unless override_files.empty?
122
+ discovery_start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
123
+ discovered = 0
124
+ lazy_test_discovery.each_test(override_files.to_a) do |example|
125
+ discovered += 1
126
+ yielder << example.queue_entry
127
+ end
128
+ duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - discovery_start
129
+ if CI::Queue.debug?
130
+ puts "[ci-queue][reconcile-discovery] discovered=#{discovered} files=#{override_files.size} " \
131
+ "duration=#{duration.round(2)}s"
132
+ end
133
+ end
134
+ end
135
+ end
136
+
137
+ def lazy_test_discovery
138
+ @lazy_test_discovery ||= begin
139
+ loader = queue.respond_to?(:file_loader) ? queue.file_loader : CI::Queue::FileLoader.new
140
+ Minitest::Queue::LazyTestDiscovery.new(loader: loader, resolver: CI::Queue::ClassResolver)
141
+ end
142
+ end
143
+
144
+ # In preresolved mode, --test-files provides the reconcile set: test files
145
+ # whose preresolved entries should be discarded and re-discovered.
146
+ # Returns an empty Set if no test files file is provided.
147
+ def reconcile_file_set
148
+ return Set.new unless test_files_file
149
+ return Set.new unless File.exist?(test_files_file)
150
+
151
+ Set.new(File.readlines(test_files_file, chomp: true).reject(&:empty?))
152
+ end
153
+
154
+ def lazy_test_enumerator
155
+ loader = queue.respond_to?(:file_loader) ? queue.file_loader : CI::Queue::FileLoader.new
156
+ resolver = CI::Queue::ClassResolver
157
+ files = test_file_list.sort
158
+
159
+ Minitest::Queue::LazyTestDiscovery.new(loader: loader, resolver: resolver).enumerator(files)
160
+ end
161
+
162
+ # Returns the list of test files to process. Prefers --test-files FILE
163
+ # (reads paths from a file, one per line) over positional argv arguments.
164
+ # --test-files avoids ARG_MAX limits for large test suites (36K+ files).
165
+ def test_file_list
166
+ @test_file_list ||= begin
167
+ if test_files_file
168
+ File.readlines(test_files_file, chomp: true).reject { |f| f.strip.empty? }
169
+ else
170
+ argv
171
+ end
172
+ end
173
+ end
174
+ end
175
+ end
176
+ end
@@ -7,6 +7,8 @@ require 'ci/queue'
7
7
  require 'digest/md5'
8
8
  require 'minitest/reporters/bisect_reporter'
9
9
  require 'minitest/reporters/statsd_reporter'
10
+ require 'minitest/queue/queue_population_strategy'
11
+ require 'minitest/queue/worker_profile_reporter'
10
12
 
11
13
  module Minitest
12
14
  module Queue
@@ -16,6 +18,10 @@ module Minitest
16
18
  Error = Class.new(StandardError)
17
19
  MissingParameter = Class.new(Error)
18
20
 
21
+ class << self
22
+ attr_accessor :run_start, :load_tests_duration, :total_files
23
+ end
24
+
19
25
  def self.invoke(argv)
20
26
  new(argv).run!
21
27
  end
@@ -49,6 +55,8 @@ module Minitest
49
55
  end
50
56
 
51
57
  def run_command
58
+ Runner.run_start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
59
+
52
60
  require_worker_id!
53
61
  # if it's an automatic job retry we should process the main queue
54
62
  if manual_retry?
@@ -98,19 +106,27 @@ module Minitest
98
106
  if remaining <= running
99
107
  puts green("Queue almost empty, exiting early...")
100
108
  else
101
- load_tests
102
- populate_queue
109
+ prepare_queue_for_execution
103
110
  end
104
111
  else
105
- load_tests
106
- populate_queue
112
+ prepare_queue_for_execution
107
113
  end
108
114
  end
109
115
 
110
- at_exit {
116
+ if queue_config.lazy_load
117
+ # In lazy-load mode, run minitest explicitly instead of relying on
118
+ # minitest/autorun's at_exit hook, which may not be registered since
119
+ # test files haven't been loaded yet. exit! prevents double-execution
120
+ # if minitest/autorun was loaded by the leader during streaming.
121
+ passed = Minitest.run []
111
122
  verify_reporters!(reporters)
112
- }
113
- # Let minitest's at_exit hook trigger
123
+ exit!(passed ? 0 : 1)
124
+ else
125
+ at_exit {
126
+ verify_reporters!(reporters)
127
+ }
128
+ # Let minitest's at_exit hook trigger
129
+ end
114
130
  end
115
131
 
116
132
  def verify_reporters!(reporters)
@@ -171,11 +187,9 @@ module Minitest
171
187
  trap('TERM') { Minitest.queue.shutdown! }
172
188
  trap('INT') { Minitest.queue.shutdown! }
173
189
 
174
- load_tests
175
-
176
190
  @queue = CI::Queue::Grind.new(grind_list, queue_config)
177
191
  Minitest.queue = queue
178
- populate_queue
192
+ prepare_queue_for_execution
179
193
 
180
194
  # Let minitest's at_exit hook trigger
181
195
  end
@@ -184,10 +198,9 @@ module Minitest
184
198
  invalid_usage! "Missing the FAILING_TEST argument." unless queue_config.failing_test
185
199
 
186
200
  set_load_path
187
- load_tests
188
201
  @queue = CI::Queue::Bisect.new(queue_url, queue_config)
189
202
  Minitest.queue = queue
190
- populate_queue
203
+ prepare_queue_for_execution
191
204
 
192
205
  step("Testing the failing test in isolation")
193
206
  unless queue.failing_test_present?
@@ -285,6 +298,7 @@ module Minitest
285
298
  reporter.write_failure_file(queue_config.failure_file) if queue_config.failure_file
286
299
  reporter.write_flaky_tests_file(queue_config.export_flaky_tests_file) if queue_config.export_flaky_tests_file
287
300
  exit_code = reporter.report
301
+ print_worker_profiles(supervisor)
288
302
  exit! exit_code
289
303
  end
290
304
 
@@ -322,7 +336,7 @@ module Minitest
322
336
 
323
337
  attr_reader :queue_config, :options, :command, :argv
324
338
  attr_writer :queue_url
325
- attr_accessor :queue, :grind_list, :grind_count, :load_paths, :verbose
339
+ attr_accessor :queue, :grind_list, :grind_count, :load_paths, :verbose, :test_files_file, :preresolved_test_list
326
340
 
327
341
  def require_worker_id!
328
342
  if queue.distributed?
@@ -372,10 +386,6 @@ module Minitest
372
386
  queue.build.reset_worker_error
373
387
  end
374
388
 
375
- def populate_queue
376
- Minitest.queue.populate(Minitest.loaded_tests, random: ordering_seed)
377
- end
378
-
379
389
  def set_load_path
380
390
  if paths = load_paths
381
391
  paths.split(':').reverse.each do |path|
@@ -384,10 +394,22 @@ module Minitest
384
394
  end
385
395
  end
386
396
 
387
- def load_tests
388
- argv.sort.each do |f|
389
- require File.expand_path(f)
390
- end
397
+ def prepare_queue_for_execution
398
+ strategy = queue_population_strategy
399
+ strategy.load_and_populate!
400
+ Runner.load_tests_duration = strategy.load_tests_duration
401
+ Runner.total_files = strategy.total_files
402
+ end
403
+
404
+ def queue_population_strategy
405
+ Minitest::Queue::QueuePopulationStrategy.new(
406
+ queue: queue,
407
+ queue_config: queue_config,
408
+ argv: argv,
409
+ test_files_file: test_files_file,
410
+ ordering_seed: ordering_seed,
411
+ preresolved_test_list: preresolved_test_list,
412
+ )
391
413
  end
392
414
 
393
415
  def parse(argv)
@@ -396,6 +418,10 @@ module Minitest
396
418
  return command, argv
397
419
  end
398
420
 
421
+ def print_worker_profiles(supervisor)
422
+ Minitest::Queue::WorkerProfileReporter.new(supervisor).print_summary
423
+ end
424
+
399
425
  def parser
400
426
  @parser ||= OptionParser.new do |opts|
401
427
  opts.banner = "Usage: minitest-queue [options] COMMAND [ARGS]"
@@ -502,7 +528,56 @@ module Minitest
502
528
  end
503
529
 
504
530
  help = <<~EOS
505
- Sepcify a seed used to shuffle the test suite.
531
+ Load test files on demand instead of eagerly loading all tests.
532
+ EOS
533
+ opts.separator ""
534
+ opts.on('--lazy-load', help) do
535
+ queue_config.lazy_load = true
536
+ end
537
+
538
+ help = <<~EOS
539
+ Batch size for streaming tests to Redis in lazy mode.
540
+ Defaults to 5000.
541
+ EOS
542
+ opts.separator ""
543
+ opts.on('--lazy-load-stream-batch-size SIZE', Integer, help) do |size|
544
+ queue_config.lazy_load_stream_batch_size = size
545
+ end
546
+
547
+ help = <<~EOS
548
+ Read test file paths from FILE (one per line) instead of positional arguments.
549
+ Useful for large test suites to avoid ARG_MAX limits.
550
+ EOS
551
+ opts.separator ""
552
+ opts.on('--test-files FILE', help) do |file|
553
+ self.test_files_file = file
554
+ end
555
+
556
+ help = <<~EOS
557
+ Read a pre-resolved test list from FILE instead of discovering tests from files.
558
+ Each line must be in the format: TestClass#method_name|path/to/test_file.rb
559
+ The leader streams entries directly to Redis without loading or discovering tests.
560
+ Implies lazy-load mode for all workers.
561
+ When combined with --test-files, entries whose file path appears in that list
562
+ are skipped and the files are lazily re-discovered (reconciliation).
563
+ EOS
564
+ opts.separator ""
565
+ opts.on('--preresolved-tests FILE', help) do |file|
566
+ self.preresolved_test_list = file
567
+ queue_config.lazy_load = true
568
+ end
569
+
570
+ help = <<~EOS
571
+ Timeout in seconds without new streamed batches before failing.
572
+ Defaults to the max of --queue-init-timeout and 300 seconds.
573
+ EOS
574
+ opts.separator ""
575
+ opts.on('--lazy-load-stream-timeout SECONDS', Integer, help) do |seconds|
576
+ queue_config.lazy_load_streaming_timeout = seconds
577
+ end
578
+
579
+ help = <<~EOS
580
+ Specify a seed used to shuffle the test suite.
506
581
  On Buildkite, CircleCI, Heroku CI, and Travis, the commit revision will be used by default.
507
582
  EOS
508
583
  opts.separator ""
@@ -137,11 +137,13 @@ module Minitest
137
137
  def error_location(exception)
138
138
  @error_location ||= begin
139
139
  last_before_assertion = ''
140
- exception.backtrace.reverse_each do |s|
140
+ backtrace_for(exception).reverse_each do |s|
141
141
  break if s =~ /in .(assert|refute|flunk|pass|fail|raise|must|wont)/
142
142
 
143
143
  last_before_assertion = s
144
144
  end
145
+ return ['unknown', 0] if last_before_assertion.empty?
146
+
145
147
  path = last_before_assertion.sub(/:in .*$/, '')
146
148
  # the path includes the linenumber at the end,
147
149
  # which is seperated by a :
@@ -151,6 +153,17 @@ module Minitest
151
153
  [result.first, result.last.to_i]
152
154
  end
153
155
  end
156
+
157
+ def backtrace_for(exception)
158
+ backtrace = exception.backtrace
159
+ return backtrace if backtrace && !backtrace.empty?
160
+
161
+ nested_exception = exception.respond_to?(:error) ? exception.error : nil
162
+ nested_backtrace = nested_exception&.backtrace
163
+ return nested_backtrace if nested_backtrace && !nested_backtrace.empty?
164
+
165
+ []
166
+ end
154
167
  end
155
168
  end
156
169
  end