Skip to content

Commit fcd85c1

Browse files
authored
Merge pull request #46 from figma/ebarajas/support-ordering-strategies
support exporting and ordering by test duration
2 parents d50711a + 2818672 commit fcd85c1

File tree

12 files changed

+265
-10
lines changed

12 files changed

+265
-10
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: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,10 @@ def run_command
7474
TestDataReporter.new(namespace: queue_config&.namespace),
7575
OrderReporter.new(path: 'log/test_order.log'),
7676
]
77+
if queue_config.track_test_duration
78+
test_time_record = CI::Queue::Redis::TestTimeRecord.new(queue_url, queue_config)
79+
reporters << TestTimeRecorder.new(build: test_time_record)
80+
end
7781
if queue_config.statsd_endpoint
7882
reporters << Minitest::Reporters::StatsdReporter.new(statsd_endpoint: queue_config.statsd_endpoint)
7983
end
@@ -218,8 +222,23 @@ def report_command
218222
File.write(queue_config.export_flaky_tests_file, failures)
219223
end
220224

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

225244
def report_grind_command
@@ -241,6 +260,7 @@ def report_grind_command
241260
build: test_time_record,
242261
limit: queue_config.max_test_duration,
243262
percentile: queue_config.max_test_duration_percentile,
263+
export_file: queue_config.export_timing_file
244264
)
245265
test_time_reporter.report
246266

@@ -564,6 +584,38 @@ def parser
564584
self.test_globs = test_globs
565585
end
566586

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

ruby/lib/minitest/queue/test_time_recorder.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ def initialize(build:, **options)
1010
def record(test)
1111
return unless test.passed?
1212
test_duration_in_milliseconds = test.time * 1000
13-
@build.record(test.name, test_duration_in_milliseconds)
13+
test_id = "#{test.klass}##{test.name}"
14+
@build.record(test_id, test_duration_in_milliseconds)
1415
end
1516
end
1617
end

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"

ruby/test/integration/grind_redis_test.rb

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -271,7 +271,7 @@ def test_grind_max_test_duration_failing
271271
+++ Test Time Report
272272
Detected 1 test(s) over the desired time limit.
273273
Please make them faster than 1.0e-05ms in the 50th percentile.
274-
test_flaky_passes:
274+
ATest#test_flaky_passes:
275275
EOS
276276
assert output.start_with?(expected.strip)
277277
assert_empty err
@@ -313,5 +313,56 @@ def test_grind_max_test_duration_percentile_outside_range
313313
refute_empty err
314314
assert err.include?("--max-test-duration-percentile must be within range (0, 1] (OptionParser::ParseError)")
315315
end
316+
317+
def test_grind_export_timing_file
318+
Dir.mktmpdir do |dir|
319+
timing_file = File.join(dir, 'grind_timing_file.json')
320+
321+
system(
322+
{ 'BUILDKITE' => '1' },
323+
@exe, 'grind',
324+
'--queue', @redis_url,
325+
'--seed', 'foobar',
326+
'--build', '1',
327+
'--worker', '1',
328+
'--timeout', '1',
329+
'--grind-count', '10',
330+
'--grind-list', 'grind_list_success.txt',
331+
'--track-test-duration',
332+
'-Itest',
333+
'test/dummy_test.rb',
334+
chdir: 'test/fixtures/',
335+
)
336+
337+
out, err = capture_subprocess_io do
338+
system(
339+
{ 'BUILDKITE' => '1' },
340+
@exe, 'report_grind',
341+
'--queue', @redis_url,
342+
'--seed', 'foobar',
343+
'--build', '1',
344+
'--worker', '1',
345+
'--timeout', '5',
346+
'--track-test-duration',
347+
'--export-timing-file', timing_file,
348+
chdir: 'test/fixtures/',
349+
)
350+
end
351+
352+
assert_empty err
353+
assert_includes out, "Exported timing data for"
354+
assert_includes out, "tests to #{timing_file}"
355+
356+
assert File.exist?(timing_file), "Timing file should exist"
357+
content = File.read(timing_file)
358+
timing_data = JSON.parse(content)
359+
360+
assert_equal 1, timing_data.size, "Should have timing data for 1 test"
361+
test_id, duration = timing_data.first
362+
assert_equal "ATest#test_flaky_passes", test_id
363+
assert_kind_of Numeric, duration, "Duration should be numeric"
364+
assert_operator duration, :>=, 0, "Duration should be non-negative"
365+
end
366+
end
316367
end
317368
end

0 commit comments

Comments
 (0)