xcknife 0.1.0 → 0.5.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/CONTRIBUTING.md +15 -0
- data/README.md +218 -1
- data/Rakefile +6 -0
- data/bin/xcknife +4 -0
- data/bin/xcknife-min +38 -0
- data/example/README.md +15 -0
- data/example/run_example.rb +34 -0
- data/example/xcknife-exemplar-historical-data.json-stream +14 -0
- data/example/xcknife-exemplar.json-stream +15 -0
- data/lib/xcknife.rb +9 -0
- data/lib/xcknife/events_analyzer.rb +55 -0
- data/lib/xcknife/exceptions.rb +6 -0
- data/lib/xcknife/json_stream_parser_helper.rb +24 -0
- data/lib/xcknife/runner.rb +138 -0
- data/lib/xcknife/stream_parser.rb +222 -0
- data/lib/xcknife/xctool_cmd_helper.rb +14 -0
- data/spec/spec_helper.rb +4 -0
- data/spec/xcknife_spec.rb +344 -0
- data/spec/xctool_cmd_helper_spec.rb +13 -0
- data/xcknife.gemspec +5 -3
- metadata +30 -6
@@ -0,0 +1,24 @@
|
|
1
|
+
module XCKnife
|
2
|
+
module JsonStreamParserHelper
|
3
|
+
extend self
|
4
|
+
|
5
|
+
# Iterates over events, calling block once for each test_target/test event on a events (a parsed json_stream iterable)
|
6
|
+
def each_test_event(events, &block)
|
7
|
+
current_target = nil
|
8
|
+
events.each do |result|
|
9
|
+
current_target = result.targetName if result.event == "begin-ocunit"
|
10
|
+
if result.test and result.event == "end-test"
|
11
|
+
raise XCKnife::StreamParsingError, "No test target defined" if current_target.nil?
|
12
|
+
block.call(current_target, normalize_result(result))
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
def normalize_result(result)
|
18
|
+
if result.totalDuration.is_a?(String)
|
19
|
+
result.totalDuration = result.totalDuration.to_f
|
20
|
+
end
|
21
|
+
result
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,138 @@
|
|
1
|
+
require 'optparse'
|
2
|
+
|
3
|
+
module XCKnife
|
4
|
+
class Runner
|
5
|
+
include XCToolCmdHelper
|
6
|
+
|
7
|
+
def self.invoke
|
8
|
+
new(ARGV).run
|
9
|
+
end
|
10
|
+
|
11
|
+
attr_reader :parser
|
12
|
+
|
13
|
+
def initialize(args)
|
14
|
+
@abbreviated_output = false
|
15
|
+
@partitions = []
|
16
|
+
@partition_names = []
|
17
|
+
@worker_count = nil
|
18
|
+
@historical_timings_file = nil
|
19
|
+
@current_tests_file = nil
|
20
|
+
@output_file_name = nil
|
21
|
+
parse_arguments(args)
|
22
|
+
end
|
23
|
+
|
24
|
+
def run
|
25
|
+
stream_parser = XCKnife::StreamParser.new(@worker_count, @partitions)
|
26
|
+
result = stream_parser.compute_shards_for_file(@historical_timings_file, @current_tests_file)
|
27
|
+
data = @abbreviated_output ? gen_abbreviated_output(result) : gen_full_output(result)
|
28
|
+
write_output(data)
|
29
|
+
rescue XCKnife::XCKnifeError => e
|
30
|
+
warn "Error: #{e}"
|
31
|
+
exit 1
|
32
|
+
end
|
33
|
+
private
|
34
|
+
def gen_abbreviated_output(result)
|
35
|
+
result.test_maps.map { |partition_set| xctool_only_arguments_for_a_partition_set(partition_set) }
|
36
|
+
end
|
37
|
+
|
38
|
+
def gen_full_output(result)
|
39
|
+
{
|
40
|
+
metadata: {
|
41
|
+
worker_count: @worker_count,
|
42
|
+
partition_set_count: result.test_maps.size,
|
43
|
+
total_time_in_ms: result.total_test_time,
|
44
|
+
}.merge(result.stats.to_h),
|
45
|
+
partition_set_data: partition_sets_data(result)
|
46
|
+
}
|
47
|
+
end
|
48
|
+
|
49
|
+
def partition_sets_data(result)
|
50
|
+
shard_number = 0
|
51
|
+
result.test_maps.each_with_index.map do |partition_set, partition_set_i|
|
52
|
+
partition_data = partition_set.each_with_index.map do |partition, partition_j|
|
53
|
+
shard_number += 1
|
54
|
+
partition_data(result, shard_number, partition, partition_set_i, partition_j)
|
55
|
+
end
|
56
|
+
|
57
|
+
{
|
58
|
+
partition_set: @partition_names[partition_set_i],
|
59
|
+
size: partition_set.size,
|
60
|
+
imbalance_ratio: result.test_time_imbalances.partition_set[partition_set_i],
|
61
|
+
partitions: partition_data
|
62
|
+
}
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
def partition_data(result, shard_number, partition, partition_set_i, partition_j)
|
67
|
+
{
|
68
|
+
shard_number: shard_number,
|
69
|
+
cli_arguments: xctool_only_arguments(partition),
|
70
|
+
partition_imbalance_ratio: result.test_time_imbalances.partitions[partition_set_i][partition_j]
|
71
|
+
}
|
72
|
+
end
|
73
|
+
|
74
|
+
def write_output(data)
|
75
|
+
json = JSON.pretty_generate(data)
|
76
|
+
return puts json if @output_file_name.nil?
|
77
|
+
File.open(@output_file_name, "w") { |f| f.puts(json) }
|
78
|
+
puts "Wrote file to: #{@output_file_name}"
|
79
|
+
end
|
80
|
+
|
81
|
+
def parse_arguments(args)
|
82
|
+
positional_arguments = parse_options(args)
|
83
|
+
if positional_arguments.size < required_arguments.size
|
84
|
+
warn_and_exit("You must specify *all* required arguments: #{required_arguments.join(", ")}")
|
85
|
+
end
|
86
|
+
if @partitions.empty?
|
87
|
+
warn_and_exit("At least one target partition set must be provided with -p flag")
|
88
|
+
end
|
89
|
+
worker_count, @historical_timings_file, @current_tests_file = positional_arguments
|
90
|
+
@worker_count = Integer(worker_count)
|
91
|
+
end
|
92
|
+
|
93
|
+
def parse_options(args)
|
94
|
+
build_parser
|
95
|
+
begin
|
96
|
+
parser.parse(args)
|
97
|
+
rescue OptionParser::ParseError => error
|
98
|
+
warn_and_exit(error)
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
def build_parser
|
103
|
+
@parser = OptionParser.new do |opts|
|
104
|
+
opts.banner += " #{arguments_banner}"
|
105
|
+
opts.on("-p", "--partition TARGETS",
|
106
|
+
"Comma separated list of targets. Can be used multiple times.") do |v|
|
107
|
+
@partition_names << v
|
108
|
+
@partitions << v.split(",")
|
109
|
+
end
|
110
|
+
opts.on("-o", "--output FILENAME", "Output file. Defaults to STDOUT") { |v| @output_file_name = v }
|
111
|
+
opts.on("-a", "--abbrev", "Results are abbreviated") { |v| @abbreviated_output = v }
|
112
|
+
|
113
|
+
opts.on_tail("-h", "--help", "Show this message") do
|
114
|
+
puts opts
|
115
|
+
exit
|
116
|
+
end
|
117
|
+
end
|
118
|
+
end
|
119
|
+
|
120
|
+
def required_arguments
|
121
|
+
%w[worker-count historical-timings-json-stream-file]
|
122
|
+
end
|
123
|
+
|
124
|
+
def optional_arguments
|
125
|
+
%w[current-tests-json-stream-file]
|
126
|
+
end
|
127
|
+
|
128
|
+
def arguments_banner
|
129
|
+
optional_args = optional_arguments.map { |a| "[#{a}]" }
|
130
|
+
(required_arguments + optional_args).join(" ")
|
131
|
+
end
|
132
|
+
|
133
|
+
def warn_and_exit(msg)
|
134
|
+
warn "#{msg.to_s.capitalize} \n\n#{parser}"
|
135
|
+
exit 1
|
136
|
+
end
|
137
|
+
end
|
138
|
+
end
|
@@ -0,0 +1,222 @@
|
|
1
|
+
require 'xcknife/json_stream_parser_helper'
|
2
|
+
require 'json'
|
3
|
+
require 'set'
|
4
|
+
require 'ostruct'
|
5
|
+
require 'forwardable'
|
6
|
+
|
7
|
+
module XCKnife
|
8
|
+
class StreamParser
|
9
|
+
include JsonStreamParserHelper
|
10
|
+
|
11
|
+
attr_reader :number_of_shards, :test_partitions, :stats, :relevant_partitions
|
12
|
+
|
13
|
+
def initialize(number_of_shards, test_partitions)
|
14
|
+
@number_of_shards = number_of_shards
|
15
|
+
@test_partitions = test_partitions.map(&:to_set)
|
16
|
+
@relevant_partitions = test_partitions.flatten.to_set
|
17
|
+
@stats = ResultStats.new
|
18
|
+
ResultStats.members.each { |k| @stats[k] = 0}
|
19
|
+
end
|
20
|
+
|
21
|
+
PartitionWithMachines = Struct.new :test_time_map, :number_of_shards, :partition_time, :max_shard_count
|
22
|
+
MachineAssignment = Struct.new :test_time_map, :total_time
|
23
|
+
ResultStats = Struct.new :historical_total_tests, :current_total_tests, :class_extrapolations, :target_extrapolations
|
24
|
+
|
25
|
+
class PartitionResult
|
26
|
+
TimeImbalances = Struct.new :partition_set, :partitions
|
27
|
+
attr_reader :stats, :test_maps, :test_times, :total_test_time, :test_time_imbalances
|
28
|
+
extend Forwardable
|
29
|
+
delegate ResultStats.members => :@stats
|
30
|
+
|
31
|
+
def initialize(stats, partition_sets)
|
32
|
+
@stats = stats
|
33
|
+
@partition_sets = partition_sets
|
34
|
+
@test_maps = partition_sets_map(&:test_time_map)
|
35
|
+
@test_times = partition_sets_map(&:total_time)
|
36
|
+
@total_test_time = test_times.flatten.inject(:+)
|
37
|
+
@test_time_imbalances = compute_test_time_imbalances
|
38
|
+
end
|
39
|
+
|
40
|
+
private
|
41
|
+
# Yields the imbalances ratios of the partition sets, and the internal imbalance ratio of the respective partitions
|
42
|
+
def compute_test_time_imbalances
|
43
|
+
times = test_times
|
44
|
+
average_partition_size = times.map { |l| l.inject(:+).to_f / l.size}
|
45
|
+
ideal_partition_set_avg = average_partition_size.inject(:+) / @partition_sets.size
|
46
|
+
partition_set_imbalance = average_partition_size.map { |avg| avg / ideal_partition_set_avg }
|
47
|
+
|
48
|
+
internal_partition_imbalance = times.map do |partition_times|
|
49
|
+
internal_total = partition_times.inject(:+)
|
50
|
+
partition_times.map do |partition_time|
|
51
|
+
(partition_time * partition_times.size).to_f / internal_total
|
52
|
+
end
|
53
|
+
end
|
54
|
+
TimeImbalances.new partition_set_imbalance, internal_partition_imbalance
|
55
|
+
end
|
56
|
+
|
57
|
+
def partition_sets_map(&block)
|
58
|
+
@partition_sets.map { |assignemnt_list| assignemnt_list.map(&block) }
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
# Parses the output of a xctool json-stream reporter and compute the shards based of that
|
63
|
+
# see: https://github.com/facebook/xctool#included-reporters
|
64
|
+
#
|
65
|
+
# @param historical_filename: String the path of the, usually historical, test time performance.
|
66
|
+
# @param current_test_filename: [String, nil] = the path of the current test names and targets,
|
67
|
+
def compute_shards_for_file(historical_filename, current_test_filename = nil)
|
68
|
+
compute_shards_for_events(parse_json_stream_file(historical_filename), parse_json_stream_file(current_test_filename))
|
69
|
+
end
|
70
|
+
|
71
|
+
def compute_shards_for_events(historical_events, current_events = nil)
|
72
|
+
compute_shards_for_partitions(test_time_for_partitions(historical_events, current_events))
|
73
|
+
end
|
74
|
+
|
75
|
+
def compute_shards_for_partitions(test_time_for_partitions)
|
76
|
+
PartitionResult.new(@stats, split_machines_proportionally(test_time_for_partitions).map do |partition|
|
77
|
+
compute_single_shards(partition.number_of_shards, partition.test_time_map)
|
78
|
+
end)
|
79
|
+
end
|
80
|
+
|
81
|
+
def test_time_for_partitions(historical_events, current_events = nil)
|
82
|
+
analyzer = EventsAnalyzer.for(current_events, relevant_partitions)
|
83
|
+
@stats[:current_total_tests] = analyzer.total_tests
|
84
|
+
times_for_target_class = Hash.new { |h, current_target| h[current_target] = Hash.new(0) }
|
85
|
+
each_test_event(historical_events) do |target_name, result|
|
86
|
+
next unless relevant_partitions.include?(target_name)
|
87
|
+
inc_stat :historical_total_tests
|
88
|
+
next unless analyzer.has_test_class?(target_name, result.className)
|
89
|
+
times_for_target_class[target_name][result.className] += (result.totalDuration * 1000).ceil
|
90
|
+
end
|
91
|
+
|
92
|
+
extrapolate_times_for_current_events(analyzer, times_for_target_class) if current_events
|
93
|
+
hash_partitions(times_for_target_class)
|
94
|
+
end
|
95
|
+
|
96
|
+
def split_machines_proportionally(partitions)
|
97
|
+
total = 0
|
98
|
+
partitions.each do |test_time_map|
|
99
|
+
each_duration(test_time_map) { |duration_in_milliseconds| total += duration_in_milliseconds}
|
100
|
+
end
|
101
|
+
|
102
|
+
used_shards = 0
|
103
|
+
assignable_shards = number_of_shards - partitions.size
|
104
|
+
partition_with_machines_list = partitions.map do |test_time_map|
|
105
|
+
partition_time = 0
|
106
|
+
max_shard_count = test_time_map.values.map(&:size).inject(:+) || 1
|
107
|
+
each_duration(test_time_map) { |duration_in_milliseconds| partition_time += duration_in_milliseconds}
|
108
|
+
n = [1 + (assignable_shards * partition_time.to_f / total).floor, max_shard_count].min
|
109
|
+
used_shards += n
|
110
|
+
PartitionWithMachines.new(test_time_map, n, partition_time, max_shard_count)
|
111
|
+
end
|
112
|
+
|
113
|
+
fifo_with_machines_who_can_use_more_shards = partition_with_machines_list.select { |x| x.number_of_shards < x.max_shard_count}.sort_by(&:partition_time)
|
114
|
+
while (number_of_shards - used_shards) > 0
|
115
|
+
if fifo_with_machines_who_can_use_more_shards.empty?
|
116
|
+
raise XCKnife::XCKnifeError, "There are #{number_of_shards - used_shards} extra machines"
|
117
|
+
end
|
118
|
+
machine = fifo_with_machines_who_can_use_more_shards.pop
|
119
|
+
machine.number_of_shards += 1
|
120
|
+
used_shards += 1
|
121
|
+
if machine.number_of_shards < machine.max_shard_count
|
122
|
+
fifo_with_machines_who_can_use_more_shards.unshift(machine)
|
123
|
+
end
|
124
|
+
end
|
125
|
+
partition_with_machines_list
|
126
|
+
end
|
127
|
+
|
128
|
+
# Computes a 2-aproximation to the optimal partition_time, which is an instance of the Open shop scheduling problem (which is NP-hard)
|
129
|
+
# see: https://en.wikipedia.org/wiki/Open-shop_scheduling
|
130
|
+
def compute_single_shards(number_of_shards, test_time_map)
|
131
|
+
raise XCKnife::XCKnifeError, "There are not enough workers provided" if number_of_shards <= 0
|
132
|
+
raise XCKnife::XCKnifeError, "Cannot shard an empty partition_time" if test_time_map.empty?
|
133
|
+
assignements = Array.new(number_of_shards) { MachineAssignment.new(Hash.new { |k, v| k[v] = [] }, 0) }
|
134
|
+
|
135
|
+
list_of_test_target_class_times = []
|
136
|
+
test_time_map.each do |test_target, class_times|
|
137
|
+
class_times.each do |class_name, duration_in_milliseconds|
|
138
|
+
list_of_test_target_class_times << [test_target, class_name, duration_in_milliseconds]
|
139
|
+
end
|
140
|
+
end
|
141
|
+
|
142
|
+
list_of_test_target_class_times.sort_by! { |test_target, class_name, duration_in_milliseconds| -duration_in_milliseconds }
|
143
|
+
list_of_test_target_class_times.each do |test_target, class_name, duration_in_milliseconds|
|
144
|
+
assignemnt = assignements.min_by(&:total_time)
|
145
|
+
assignemnt.test_time_map[test_target] << class_name
|
146
|
+
assignemnt.total_time += duration_in_milliseconds
|
147
|
+
end
|
148
|
+
raise XCKnife::XCKnifeError, "Too many shards" if assignements.any? { |a| a.test_time_map.empty? }
|
149
|
+
assignements
|
150
|
+
end
|
151
|
+
|
152
|
+
def parse_json_stream_file(filename)
|
153
|
+
return nil if filename.nil?
|
154
|
+
return [] unless File.exists?(filename)
|
155
|
+
lines = IO.readlines(filename)
|
156
|
+
lines.lazy.map { |line| OpenStruct.new(JSON.load(line)) }
|
157
|
+
end
|
158
|
+
|
159
|
+
private
|
160
|
+
def inc_stat(name)
|
161
|
+
@stats[name] += 1
|
162
|
+
end
|
163
|
+
|
164
|
+
def each_duration(test_time_map, &block)
|
165
|
+
test_time_map.each do |test_target, class_times|
|
166
|
+
class_times.each do |class_name, duration_in_milliseconds|
|
167
|
+
yield(duration_in_milliseconds)
|
168
|
+
end
|
169
|
+
end
|
170
|
+
end
|
171
|
+
|
172
|
+
def extrapolate_times_for_current_events(analyzer, times_for_target_class)
|
173
|
+
median_map = {}
|
174
|
+
times_for_target_class.each do |test_target, class_times|
|
175
|
+
median_map[test_target] = median(class_times.values)
|
176
|
+
end
|
177
|
+
|
178
|
+
all_times_for_all_classes = times_for_target_class.values.flat_map(&:values)
|
179
|
+
median_of_targets = median(all_times_for_all_classes)
|
180
|
+
analyzer.target_class_map.each do |test_target, class_set|
|
181
|
+
if times_for_target_class.has_key?(test_target)
|
182
|
+
class_set.each do |clazz|
|
183
|
+
unless times_for_target_class[test_target].has_key?(clazz)
|
184
|
+
inc_stat :class_extrapolations
|
185
|
+
times_for_target_class[test_target][clazz] = median_map[test_target]
|
186
|
+
end
|
187
|
+
end
|
188
|
+
else
|
189
|
+
inc_stat :target_extrapolations
|
190
|
+
class_set.each do |clazz|
|
191
|
+
inc_stat :class_extrapolations
|
192
|
+
times_for_target_class[test_target][clazz] = extrapolated_duration(median_of_targets, class_set)
|
193
|
+
end
|
194
|
+
end
|
195
|
+
end
|
196
|
+
end
|
197
|
+
|
198
|
+
DEFAULT_EXTRAPOLATED_DURATION = 1000
|
199
|
+
def extrapolated_duration(median_of_targets, class_set)
|
200
|
+
return DEFAULT_EXTRAPOLATED_DURATION if median_of_targets.nil?
|
201
|
+
median_of_targets / class_set.size
|
202
|
+
end
|
203
|
+
|
204
|
+
def median(array)
|
205
|
+
array.sort[array.size / 2]
|
206
|
+
end
|
207
|
+
|
208
|
+
def hash_partitions(times)
|
209
|
+
ret = Array.new(test_partitions.size) { {} }
|
210
|
+
times.each do |test_target, times_map|
|
211
|
+
test_partitions.each_with_index do |partition, i|
|
212
|
+
ret[i][test_target] = times_map if partition.include?(test_target)
|
213
|
+
end
|
214
|
+
end
|
215
|
+
ret.each_with_index do |partition, index|
|
216
|
+
if partition.empty?
|
217
|
+
raise XCKnife::XCKnifeError, "The following partition has no tests: #{test_partitions[index].to_a.inspect}"
|
218
|
+
end
|
219
|
+
end
|
220
|
+
end
|
221
|
+
end
|
222
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
require 'pp'
|
2
|
+
module XCKnife
|
3
|
+
module XCToolCmdHelper
|
4
|
+
def xctool_only_arguments(single_partition)
|
5
|
+
single_partition.flat_map do |test_target, classes|
|
6
|
+
['-only', "#{test_target}:#{classes.sort.join(',')}"]
|
7
|
+
end
|
8
|
+
end
|
9
|
+
|
10
|
+
def xctool_only_arguments_for_a_partition_set(partition_set)
|
11
|
+
partition_set.map { |partition| xctool_only_arguments(partition) }
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
data/spec/spec_helper.rb
ADDED
@@ -0,0 +1,344 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe XCKnife::StreamParser do
|
4
|
+
context 'test_time_for_partitions' do
|
5
|
+
subject { XCKnife::StreamParser.new(2, [["TestTarget1"], ["TestTarget2"]]) }
|
6
|
+
|
7
|
+
it 'decide how many shards each partition set needs' do
|
8
|
+
stream = [xctool_target_event("TestTarget1"),
|
9
|
+
xctool_test_event("ClassTest1", "test1"),
|
10
|
+
xctool_target_event("TestTarget2"),
|
11
|
+
xctool_test_event("ClassTest2", "test1")
|
12
|
+
]
|
13
|
+
result = subject.test_time_for_partitions(stream)
|
14
|
+
expect(result).to eq([{ "TestTarget1" => { "ClassTest1" => 1000 } },
|
15
|
+
{ "TestTarget2" => { "ClassTest2" => 1000 } }])
|
16
|
+
end
|
17
|
+
|
18
|
+
it 'aggretates the times at the class level' do
|
19
|
+
stream_parser = XCKnife::StreamParser.new(2, [["TestTarget1"]])
|
20
|
+
stream = [xctool_target_event("TestTarget1"),
|
21
|
+
xctool_test_event("ClassTest1", "test1", 1.0),
|
22
|
+
xctool_test_event("ClassTest1", "test2", 2.0)
|
23
|
+
]
|
24
|
+
result = stream_parser.test_time_for_partitions(stream)
|
25
|
+
expect(result).to eq([{ "TestTarget1" => { "ClassTest1" => 3000 } }])
|
26
|
+
end
|
27
|
+
|
28
|
+
it 'works with multiple partitions' do
|
29
|
+
stream_parser = XCKnife::StreamParser.new(2, [["TestTarget1"], ["TestTarget2"], ["TestTarget3"]])
|
30
|
+
|
31
|
+
stream = [xctool_target_event("TestTarget1"),
|
32
|
+
xctool_test_event("Class1", "test1"),
|
33
|
+
xctool_target_event("TestTarget2"),
|
34
|
+
xctool_test_event("Class2", "test1"),
|
35
|
+
xctool_target_event("TestTarget3"),
|
36
|
+
xctool_test_event("Class3", "test1"),
|
37
|
+
]
|
38
|
+
result = stream_parser.test_time_for_partitions(stream)
|
39
|
+
expect(result).to eq([{ "TestTarget1" => { "Class1" => 1000 } },
|
40
|
+
{ "TestTarget2" => { "Class2" => 1000 } },
|
41
|
+
{ "TestTarget3" => { "Class3" => 1000 } }])
|
42
|
+
end
|
43
|
+
|
44
|
+
it 'allows the same target to be listed on multiple partitions' do
|
45
|
+
stream_parser = XCKnife::StreamParser.new(2, [["TestTarget1"], ["TestTarget2", "TestTarget1"]])
|
46
|
+
stream = [xctool_target_event("TestTarget1"),
|
47
|
+
xctool_test_event("ClassTest1", "test1"),
|
48
|
+
xctool_target_event("TestTarget2"),
|
49
|
+
xctool_test_event("ClassTest2", "test1"),
|
50
|
+
]
|
51
|
+
result = stream_parser.test_time_for_partitions(stream)
|
52
|
+
expect(result).to eq([{ "TestTarget1" => { "ClassTest1" => 1000 } },
|
53
|
+
{ "TestTarget2" => { "ClassTest2" => 1000 },
|
54
|
+
"TestTarget1" => { "ClassTest1" => 1000 }
|
55
|
+
}])
|
56
|
+
end
|
57
|
+
|
58
|
+
it 'raises error when an empty partition is specified' do
|
59
|
+
stream_parser = XCKnife::StreamParser.new(1, [["TestTarget1"]])
|
60
|
+
expect { stream_parser.test_time_for_partitions([]) }.to raise_error(XCKnife::XCKnifeError, 'The following partition has no tests: ["TestTarget1"]')
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
context 'provided historical events' do
|
65
|
+
subject { XCKnife::StreamParser.new(2, [["TestTarget1", "TestTarget2", "TestTarget3", "NewTestTarget1"]]) }
|
66
|
+
|
67
|
+
it 'ignores test targets not present on current events' do
|
68
|
+
historical_events = [xctool_target_event("TestTarget1"),
|
69
|
+
xctool_test_event("ClassTest1", "test1"),
|
70
|
+
xctool_test_event("ClassTest1", "test2"),
|
71
|
+
xctool_target_event("TestTarget2"),
|
72
|
+
xctool_test_event("ClassTest2", "test1"),
|
73
|
+
xctool_test_event("ClassTest2", "test2")
|
74
|
+
]
|
75
|
+
current_events = [
|
76
|
+
xctool_target_event("TestTarget1"),
|
77
|
+
xctool_test_event("ClassTest1", "test1")
|
78
|
+
]
|
79
|
+
result = subject.test_time_for_partitions(historical_events, current_events)
|
80
|
+
expect(result).to eq([{ "TestTarget1" => { "ClassTest1" => 2000 } }])
|
81
|
+
expect(subject.stats.to_h).to eq({historical_total_tests: 4, current_total_tests: 1, class_extrapolations: 0, target_extrapolations: 0})
|
82
|
+
end
|
83
|
+
|
84
|
+
|
85
|
+
it 'ignores test classes not present on current events' do
|
86
|
+
historical_events = [xctool_target_event("TestTarget1"),
|
87
|
+
xctool_test_event("ClassTest1", "test1"),
|
88
|
+
xctool_test_event("ClassTest1", "test2"),
|
89
|
+
xctool_test_event("ClassTest2", "test1"),
|
90
|
+
xctool_test_event("ClassTest2", "test2")
|
91
|
+
]
|
92
|
+
current_events = [
|
93
|
+
xctool_target_event("TestTarget1"),
|
94
|
+
xctool_test_event("ClassTest1", "test1")
|
95
|
+
]
|
96
|
+
result = subject.test_time_for_partitions(historical_events, current_events)
|
97
|
+
expect(result).to eq([{ "TestTarget1" => { "ClassTest1" => 2000 } }])
|
98
|
+
expect(subject.stats.to_h).to eq({historical_total_tests: 4, current_total_tests: 1, class_extrapolations: 0, target_extrapolations: 0})
|
99
|
+
end
|
100
|
+
|
101
|
+
it 'extrapolates for new test targets' do
|
102
|
+
historical_events = [
|
103
|
+
xctool_target_event("TestTarget1"),
|
104
|
+
xctool_test_event("ClassTest1", "test1")
|
105
|
+
]
|
106
|
+
current_events = [
|
107
|
+
xctool_target_event("TestTarget1"),
|
108
|
+
xctool_test_event("ClassTest1", "test1"),
|
109
|
+
xctool_target_event("NewTestTargetButNotRelevant"),
|
110
|
+
xctool_test_event("ClassTest10", "test1")
|
111
|
+
]
|
112
|
+
result = subject.test_time_for_partitions(historical_events, current_events)
|
113
|
+
expect(result.to_set).to eq([{
|
114
|
+
"TestTarget1" => { "ClassTest1" => 1000 }
|
115
|
+
}
|
116
|
+
].to_set)
|
117
|
+
expect(subject.stats.to_h).to eq({historical_total_tests: 1, current_total_tests: 1, class_extrapolations: 0, target_extrapolations: 0})
|
118
|
+
end
|
119
|
+
|
120
|
+
it 'extrapolates for new test classes' do
|
121
|
+
historical_events = [
|
122
|
+
xctool_target_event("TestTarget1"),
|
123
|
+
xctool_test_event("ClassTest1", "test1", 1.0),
|
124
|
+
xctool_test_event("ClassTest2", "test2", 5.0),
|
125
|
+
xctool_test_event("ClassTest3", "test3", 10000.0)
|
126
|
+
]
|
127
|
+
current_events = historical_events + [
|
128
|
+
xctool_target_event("TestTarget1"),
|
129
|
+
xctool_test_event("ClassTest2", "test2"),
|
130
|
+
xctool_test_event("ClassTestNew", "test1")
|
131
|
+
]
|
132
|
+
result = subject.test_time_for_partitions(historical_events, current_events)
|
133
|
+
median = 5000
|
134
|
+
expect(result).to eq([{
|
135
|
+
"TestTarget1" =>
|
136
|
+
{
|
137
|
+
"ClassTest1" => 1000,
|
138
|
+
"ClassTest2" => 5000,
|
139
|
+
"ClassTest3" => 10000000,
|
140
|
+
"ClassTestNew" => median
|
141
|
+
},
|
142
|
+
}])
|
143
|
+
expect(subject.stats.to_h).to eq({historical_total_tests: 3, current_total_tests: 5, class_extrapolations: 1, target_extrapolations: 0})
|
144
|
+
end
|
145
|
+
|
146
|
+
it "ignores test classes that don't belong to relevant targets" do
|
147
|
+
historical_events = [
|
148
|
+
xctool_target_event("TestTarget1"),
|
149
|
+
xctool_test_event("ClassTest1", "test1", 1.0),
|
150
|
+
xctool_test_event("ClassTest2", "test2", 5.0),
|
151
|
+
xctool_test_event("ClassTest3", "test3", 10000.0)
|
152
|
+
]
|
153
|
+
current_events = historical_events + [
|
154
|
+
xctool_target_event("TestTarget1"),
|
155
|
+
xctool_test_event("ClassTest2", "test2"),
|
156
|
+
xctool_test_event("ClassTestNew", "test1")
|
157
|
+
]
|
158
|
+
result = subject.test_time_for_partitions(historical_events, current_events)
|
159
|
+
median = 5000
|
160
|
+
expect(result).to eq([{
|
161
|
+
"TestTarget1" =>
|
162
|
+
{
|
163
|
+
"ClassTest1" => 1000,
|
164
|
+
"ClassTest2" => 5000,
|
165
|
+
"ClassTest3" => 10000000,
|
166
|
+
"ClassTestNew" => median
|
167
|
+
},
|
168
|
+
}])
|
169
|
+
expect(subject.stats.to_h).to eq({historical_total_tests: 3, current_total_tests: 5, class_extrapolations: 1, target_extrapolations: 0})
|
170
|
+
end
|
171
|
+
end
|
172
|
+
|
173
|
+
context 'provided an empty set of applicable historical events' do
|
174
|
+
subject { XCKnife::StreamParser.new(2, [["TestTarget1", "TestTarget2", "TestTarget3", "NewTestTarget1"]]) }
|
175
|
+
|
176
|
+
let(:empty_historical_events) { [] }
|
177
|
+
let(:default_extrapolated_duration) { XCKnife::StreamParser::DEFAULT_EXTRAPOLATED_DURATION }
|
178
|
+
|
179
|
+
it 'extrapolates the test target duration and classes get extrapolated' do
|
180
|
+
current_events = [
|
181
|
+
xctool_target_event("TestTarget1"),
|
182
|
+
xctool_test_event("ClassTest1", "test1")
|
183
|
+
]
|
184
|
+
result = subject.test_time_for_partitions(empty_historical_events, current_events)
|
185
|
+
expect(result).to eq([{ "TestTarget1" => { "ClassTest1" => default_extrapolated_duration } }])
|
186
|
+
end
|
187
|
+
|
188
|
+
it 'extrapolates the test target to different classes' do
|
189
|
+
effectively_empty_historical_events = [
|
190
|
+
xctool_target_event("TestTarget2"),
|
191
|
+
xctool_test_event("IgnoredClass", "ignoredTest"),
|
192
|
+
]
|
193
|
+
current_events = [
|
194
|
+
xctool_target_event("TestTarget1"),
|
195
|
+
xctool_test_event("ClassTest1", "test1"),
|
196
|
+
xctool_test_event("ClassTest2", "test2")
|
197
|
+
]
|
198
|
+
result = subject.test_time_for_partitions(effectively_empty_historical_events, current_events)
|
199
|
+
duration = default_extrapolated_duration
|
200
|
+
expect(result).to eq([{ "TestTarget1" => { "ClassTest1" => duration, "ClassTest2" => duration } }])
|
201
|
+
end
|
202
|
+
|
203
|
+
it "can handle multiple test targets and test classes" do
|
204
|
+
current_events = [
|
205
|
+
xctool_target_event("TestTarget1"),
|
206
|
+
xctool_test_event("ClassTest11", "test1"),
|
207
|
+
xctool_test_event("ClassTest12", "test1"),
|
208
|
+
xctool_test_event("ClassTest13", "test1"),
|
209
|
+
xctool_target_event("TestTarget2"),
|
210
|
+
xctool_test_event("ClassTest21", "test1"),
|
211
|
+
xctool_test_event("ClassTest22", "test1"),
|
212
|
+
xctool_test_event("ClassTest23", "test1"),
|
213
|
+
xctool_target_event("TestTarget3"),
|
214
|
+
xctool_test_event("ClassTest31", "test1"),
|
215
|
+
xctool_test_event("ClassTest32", "test1"),
|
216
|
+
xctool_test_event("ClassTest33", "test1"),
|
217
|
+
]
|
218
|
+
result = subject.test_time_for_partitions(empty_historical_events, current_events)
|
219
|
+
duration = default_extrapolated_duration
|
220
|
+
expect(result).to eq(
|
221
|
+
[
|
222
|
+
{
|
223
|
+
"TestTarget1" => { "ClassTest11" => duration, "ClassTest12" => duration, "ClassTest13" => duration },
|
224
|
+
"TestTarget2" => { "ClassTest21" => duration, "ClassTest22" => duration, "ClassTest23" => duration },
|
225
|
+
"TestTarget3" => { "ClassTest31" => duration, "ClassTest32" => duration, "ClassTest33" => duration } }]
|
226
|
+
)
|
227
|
+
end
|
228
|
+
end
|
229
|
+
|
230
|
+
it "can split_machines_proportionally" do
|
231
|
+
stream_parser = XCKnife::StreamParser.new(5, [["TargetOnPartition1"], ["TargetOnPartition2"]])
|
232
|
+
result = stream_parser.split_machines_proportionally([
|
233
|
+
{ "TargetOnPartition1" => { "TestClass1" => 500, "TestClass2" => 500 } },
|
234
|
+
{ "TargetOnPartition2" => { "TestClass3" => 1000, "TestClass4" => 1000, "TestClass5" => 1000, "TestClass6" => 1000 } }])
|
235
|
+
expect(result.map(&:number_of_shards)).to eq([1, 4])
|
236
|
+
end
|
237
|
+
|
238
|
+
it "can split_machines_proportionally even when in the presence of large imbalances" do
|
239
|
+
stream_parser = XCKnife::StreamParser.new(5, [["TargetOnPartition1"], ["TargetOnPartition2"], ["TargetOnPartition3"]])
|
240
|
+
result = stream_parser.split_machines_proportionally([{ "TargetOnPartition1" => { "TestClass1" => 1 } },
|
241
|
+
{ "TargetOnPartition2" => { "TestClass2" => 1} },
|
242
|
+
{ "TargetOnPartition3" => { "TestClass3" => 1000, "TestClass4" => 1000, "TestClass5" => 1000} }])
|
243
|
+
expect(result.map(&:number_of_shards)).to eq([1, 1, 3])
|
244
|
+
end
|
245
|
+
|
246
|
+
|
247
|
+
it "should never let partition_sets have less than 1 machine alocated to them" do
|
248
|
+
stream_parser = XCKnife::StreamParser.new(3, [["TestTarget1"], ["TestTarget2"]])
|
249
|
+
result = stream_parser.split_machines_proportionally([{ "TargetOnPartition1" => { "TestClass1" => 1 } },
|
250
|
+
{ "TargetOnPartition2" => { "TestClass2" => 2000, "TestClass3" => 2000 } }])
|
251
|
+
expect(result.map(&:number_of_shards)).to eq([1, 2])
|
252
|
+
end
|
253
|
+
|
254
|
+
|
255
|
+
context 'test_time_for_partitions' do
|
256
|
+
it "partitions the test classes accross the number of machines" do
|
257
|
+
stream_parser = XCKnife::StreamParser.new(2, [["Test Target"]])
|
258
|
+
partition = { "Test Target" => { "Class1" => 1000, "Class2" => 1000, "Class3" => 2000 } }
|
259
|
+
shards = stream_parser.compute_single_shards(2, partition).map(&:test_time_map)
|
260
|
+
expect(shards.size).to eq 2
|
261
|
+
first_shard, second_shard = shards.sort_by { |map| map.values.flatten.size }
|
262
|
+
expect(first_shard.keys).to eq(["Test Target"])
|
263
|
+
expect(first_shard.values).to eq([["Class3"]])
|
264
|
+
|
265
|
+
expect(second_shard.keys).to eq(["Test Target"])
|
266
|
+
expect(second_shard.values.map(&:to_set)).to eq([["Class1", "Class2"].to_set])
|
267
|
+
end
|
268
|
+
|
269
|
+
it "partitions the test, across targets" do
|
270
|
+
stream_parser = XCKnife::StreamParser.new(2, [["Test Target1", "Test Target2", "Test Target3"]])
|
271
|
+
partition = { "Test Target1" => { "Class1" => 1000 },
|
272
|
+
"Test Target2" => { "Class2" => 1000 },
|
273
|
+
"Test Target3" => { "Class3" => 2000 } }
|
274
|
+
shards = stream_parser.compute_single_shards(2, partition).map(&:test_time_map)
|
275
|
+
expect(shards.size).to eq 2
|
276
|
+
first_shard, second_shard = shards.sort_by { |map| map.values.flatten.size }
|
277
|
+
expect(first_shard.keys).to eq(["Test Target3"])
|
278
|
+
expect(first_shard.values).to eq([["Class3"]])
|
279
|
+
|
280
|
+
expect(second_shard.keys.to_set).to eq(["Test Target1", "Test Target2"].to_set)
|
281
|
+
expect(second_shard.values.to_set).to eq([["Class1"], ["Class2"]].to_set)
|
282
|
+
end
|
283
|
+
|
284
|
+
it "raises an error if there are too many shards" do
|
285
|
+
too_many_machines = 2
|
286
|
+
stream_parser = XCKnife::StreamParser.new(too_many_machines, [["Test Target1"]])
|
287
|
+
partition = { "Test Target1" => { "Class1" => 1000 } }
|
288
|
+
expect { stream_parser.compute_single_shards(too_many_machines, partition) }.
|
289
|
+
to raise_error(XCKnife::XCKnifeError, "Too many shards")
|
290
|
+
end
|
291
|
+
end
|
292
|
+
|
293
|
+
it "can compute test for all partitions" do
|
294
|
+
stream_parser = XCKnife::StreamParser.new(3, [["TargetOnPartition1"], ["TargetOnPartition2"]])
|
295
|
+
result = stream_parser.compute_shards_for_partitions([{ "TargetOnPartition1" => { "TestClass1" => 1000 } },
|
296
|
+
{ "TargetOnPartition2" => { "TestClass2" => 4000, "TestClass3" => 4000 } }])
|
297
|
+
expect(result.test_maps).to eq([[{ "TargetOnPartition1" => ["TestClass1"] }],
|
298
|
+
[{ "TargetOnPartition2" => ["TestClass2"] },
|
299
|
+
{ "TargetOnPartition2" => ["TestClass3"] }]])
|
300
|
+
expect(result.test_times).to eq [[1000], [4000, 4000]]
|
301
|
+
expect(result.total_test_time).to eq 9000
|
302
|
+
expect(result.test_time_imbalances.to_h).to eq({
|
303
|
+
partition_set: [0.4, 1.6],
|
304
|
+
partitions: [[1.0], [1.0, 1.0]]
|
305
|
+
})
|
306
|
+
end
|
307
|
+
|
308
|
+
it "can compute for only one partition set" do
|
309
|
+
stream_parser = XCKnife::StreamParser.new(1, [["TargetOnPartition1"]])
|
310
|
+
historical_events = [xctool_target_event("TargetOnPartition1"),
|
311
|
+
xctool_test_event("ClassTest1", "test1"),
|
312
|
+
]
|
313
|
+
result = stream_parser.compute_shards_for_events(historical_events)
|
314
|
+
expect(result.test_maps).to eq([[{ "TargetOnPartition1" => ["ClassTest1"] }]])
|
315
|
+
expect(result.test_times).to eq [[1000]]
|
316
|
+
expect(result.total_test_time).to eq 1000
|
317
|
+
expect(result.stats.to_h).to eq({historical_total_tests: 1, current_total_tests: 0, class_extrapolations: 0, target_extrapolations: 0})
|
318
|
+
expect(result.test_time_imbalances.to_h).to eq({
|
319
|
+
partition_set: [1.0],
|
320
|
+
partitions: [[1.0]]
|
321
|
+
})
|
322
|
+
end
|
323
|
+
|
324
|
+
def xctool_test_event(class_name, method_name, duration = 1.0)
|
325
|
+
OpenStruct.new({ result: "success",
|
326
|
+
exceptions: [],
|
327
|
+
test: "-[#{class_name} #{method_name}]",
|
328
|
+
className: class_name,
|
329
|
+
event: "end-test",
|
330
|
+
methodName: method_name,
|
331
|
+
succeeded: true,
|
332
|
+
output: "",
|
333
|
+
totalDuration: duration,
|
334
|
+
timestamp: 0
|
335
|
+
})
|
336
|
+
end
|
337
|
+
|
338
|
+
def xctool_target_event(target_name)
|
339
|
+
OpenStruct.new({ result: "success",
|
340
|
+
event: "begin-ocunit",
|
341
|
+
targetName: target_name
|
342
|
+
})
|
343
|
+
end
|
344
|
+
end
|