Skip to content

Commit 9a1776f

Browse files
committed
support exporting and ordering by test duration
1 parent 6892620 commit 9a1776f

File tree

8 files changed

+155
-6
lines changed

8 files changed

+155
-6
lines changed

ruby/lib/ci/queue.rb

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@
1313
require 'ci/queue/file'
1414
require 'ci/queue/grind'
1515
require 'ci/queue/bisect'
16+
require 'ci/queue/strategy/base'
17+
require 'ci/queue/strategy/random'
18+
require 'ci/queue/strategy/timing_based'
1619

1720
module CI
1821
module Queue
@@ -28,11 +31,12 @@ def requeueable?(test_result)
2831
requeueable.nil? || requeueable.call(test_result)
2932
end
3033

31-
def shuffle(tests, random)
34+
def shuffle(tests, random, config: nil)
3235
if shuffler
3336
shuffler.call(tests, random)
3437
else
35-
tests.sort.shuffle(random: random)
38+
strategy = get_strategy(config&.strategy)
39+
strategy.order_tests(tests, random: random, config: config)
3640
end
3741
end
3842

@@ -51,5 +55,16 @@ def from_uri(url, config)
5155
end
5256
implementation.from_uri(uri, config)
5357
end
58+
59+
private
60+
61+
def get_strategy(strategy_name)
62+
case strategy_name&.to_sym
63+
when :timing_based
64+
Strategy::TimingBased.new
65+
else
66+
Strategy::Random.new
67+
end
68+
end
5469
end
5570
end

ruby/lib/ci/queue/configuration.rb

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ class Configuration
88
attr_accessor :requeue_tolerance, :namespace, :failing_test, :statsd_endpoint
99
attr_accessor :max_test_duration, :max_test_duration_percentile, :track_test_duration
1010
attr_accessor :max_test_failed, :redis_ttl
11+
attr_accessor :strategy, :timing_file, :timing_fallback_duration, :export_timing_file
1112
attr_reader :circuit_breakers
1213
attr_writer :seed, :build_id
1314
attr_writer :queue_init_timeout, :report_timeout, :inactive_workers_timeout
@@ -51,7 +52,8 @@ def initialize(
5152
grind_count: nil, max_duration: nil, failure_file: nil, max_test_duration: nil,
5253
max_test_duration_percentile: 0.5, track_test_duration: false, max_test_failed: nil,
5354
queue_init_timeout: nil, redis_ttl: 8 * 60 * 60, report_timeout: nil, inactive_workers_timeout: nil,
54-
export_flaky_tests_file: nil, known_flaky_tests: []
55+
export_flaky_tests_file: nil, known_flaky_tests: [],
56+
strategy: :random, timing_file: nil, timing_fallback_duration: 100.0, export_timing_file: nil
5557
)
5658
@build_id = build_id
5759
@circuit_breakers = [CircuitBreaker::Disabled]
@@ -77,6 +79,10 @@ def initialize(
7779
@report_timeout = report_timeout
7880
@inactive_workers_timeout = inactive_workers_timeout
7981
@export_flaky_tests_file = export_flaky_tests_file
82+
@strategy = strategy
83+
@timing_file = timing_file
84+
@timing_fallback_duration = timing_fallback_duration
85+
@export_timing_file = export_timing_file
8086
end
8187

8288
def queue_init_timeout

ruby/lib/ci/queue/redis/worker.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ def distributed?
3030

3131
def populate(tests, random: Random.new)
3232
@index = tests.map { |t| [t.id, t] }.to_h
33-
tests = Queue.shuffle(tests, random)
33+
tests = Queue.shuffle(tests, random, config: config)
3434
push(tests.map(&:id))
3535
self
3636
end

ruby/lib/ci/queue/strategy/base.rb

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# frozen_string_literal: true
2+
3+
module CI
4+
module Queue
5+
module Strategy
6+
class Base
7+
def order_tests(tests, random: Random.new, config: nil)
8+
raise NotImplementedError, "#{self.class} must implement #order_tests"
9+
end
10+
end
11+
end
12+
end
13+
end
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# frozen_string_literal: true
2+
require_relative 'base'
3+
4+
module CI
5+
module Queue
6+
module Strategy
7+
class Random < Base
8+
def order_tests(tests, random: Random.new, config: nil)
9+
tests.sort.shuffle(random: random)
10+
end
11+
end
12+
end
13+
end
14+
end
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# frozen_string_literal: true
2+
require_relative 'base'
3+
require 'json'
4+
5+
module CI
6+
module Queue
7+
module Strategy
8+
class TimingBased < Base
9+
def order_tests(tests, random: Random.new, config: nil)
10+
timing_data = load_timing_data(config&.timing_file)
11+
fallback_duration = config&.timing_fallback_duration || 100.0
12+
13+
tests.sort_by do |test|
14+
duration = timing_data[test.id] || fallback_duration
15+
-duration # Negative for descending order (longest first)
16+
end
17+
end
18+
19+
private
20+
21+
def load_timing_data(file_path)
22+
return {} unless file_path && ::File.exist?(file_path)
23+
24+
JSON.parse(::File.read(file_path))
25+
rescue JSON::ParserError => e
26+
warn "Warning: Could not parse timing file #{file_path}: #{e.message}"
27+
{}
28+
rescue => e
29+
warn "Warning: Could not read timing file #{file_path}: #{e.message}"
30+
{}
31+
end
32+
end
33+
end
34+
end
35+
end

ruby/lib/minitest/queue/runner.rb

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,8 +218,23 @@ def report_command
218218
File.write(queue_config.export_flaky_tests_file, failures)
219219
end
220220

221+
# Handle timing data reporting and export
222+
test_time_reporter_success = if queue_config.track_test_duration
223+
test_time_record = CI::Queue::Redis::TestTimeRecord.new(queue_url, queue_config)
224+
test_time_reporter = TestTimeReporter.new(
225+
build: test_time_record,
226+
limit: queue_config.max_test_duration,
227+
percentile: queue_config.max_test_duration_percentile,
228+
export_file: queue_config.export_timing_file
229+
)
230+
test_time_reporter.report
231+
test_time_reporter.success?
232+
else
233+
true
234+
end
235+
221236
reporter.report
222-
exit! reporter.success? ? 0 : 1
237+
exit! reporter.success? && test_time_reporter_success ? 0 : 1
223238
end
224239

225240
def report_grind_command
@@ -241,6 +256,7 @@ def report_grind_command
241256
build: test_time_record,
242257
limit: queue_config.max_test_duration,
243258
percentile: queue_config.max_test_duration_percentile,
259+
export_file: queue_config.export_timing_file
244260
)
245261
test_time_reporter.report
246262

@@ -564,6 +580,38 @@ def parser
564580
self.test_globs = test_globs
565581
end
566582

583+
help = <<~EOS
584+
Test ordering strategy: random, timing_based (default: random)
585+
EOS
586+
opts.separator ""
587+
opts.on('--strategy STRATEGY', help) do |strategy|
588+
queue_config.strategy = strategy.to_sym
589+
end
590+
591+
help = <<~EOS
592+
Path to JSON timing file for timing_based strategy
593+
EOS
594+
opts.separator ""
595+
opts.on('--timing-file PATH', help) do |path|
596+
queue_config.timing_file = path
597+
end
598+
599+
help = <<~EOS
600+
Fallback duration in ms for unknown tests (default: 100)
601+
EOS
602+
opts.separator ""
603+
opts.on('--timing-fallback DURATION', Float, help) do |duration|
604+
queue_config.timing_fallback_duration = duration
605+
end
606+
607+
help = <<~EOS
608+
Export test timing data to JSON file after run (use with report command)
609+
EOS
610+
opts.separator ""
611+
opts.on('--export-timing-file PATH', help) do |path|
612+
queue_config.export_timing_file = path
613+
end
614+
567615
opts.separator ""
568616
opts.separator " retry: Replays a previous run in the same order."
569617

ruby/lib/minitest/queue/test_time_reporter.rb

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,24 @@
11
# frozen_string_literal: true
22
require 'minitest/reporters'
3+
require 'json'
34

45
module Minitest
56
module Queue
67
class TestTimeReporter < Minitest::Reporters::BaseReporter
78
include ::CI::Queue::OutputHelpers
89

9-
def initialize(build:, limit: nil, percentile: nil, **options)
10+
def initialize(build:, limit: nil, percentile: nil, export_file: nil, **options)
1011
super(options)
1112
@test_time_hash = build.fetch
1213
@limit = limit
1314
@percentile = percentile
15+
@export_file = export_file
1416
@success = true
1517
end
1618

1719
def report
20+
export_timing_data if @export_file
21+
1822
return if limit.nil? || test_time_hash.empty?
1923

2024
puts '+++ Test Time Report'
@@ -47,6 +51,20 @@ def record(*)
4751

4852
attr_reader :test_time_hash, :limit, :percentile
4953

54+
def export_timing_data
55+
return if test_time_hash.empty?
56+
57+
# Convert test_time_hash to simple format: {"TestClass#method": avg_duration_ms}
58+
timing_data = test_time_hash.transform_values do |durations|
59+
durations.sum.to_f / durations.size # Average duration
60+
end
61+
62+
File.write(@export_file, JSON.pretty_generate(timing_data))
63+
puts "Exported timing data for #{timing_data.size} tests to #{@export_file}"
64+
rescue => e
65+
puts "Warning: Failed to export timing data to #{@export_file}: #{e.message}"
66+
end
67+
5068
def humanized_percentile
5169
percentile_in_percentage = percentile * 100
5270
"#{percentile_in_percentage.to_i}th"

0 commit comments

Comments
 (0)