binpacker 0.3.0 → 0.5.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.
- checksums.yaml +4 -4
- data/exe/binpacker-worker +12 -2
- data/lib/binpacker/cli.rb +85 -47
- data/lib/binpacker/orchestrator.rb +98 -40
- data/lib/binpacker/progress.rb +15 -9
- data/lib/binpacker/report.rb +33 -3
- data/lib/binpacker/shard.rb +62 -0
- data/lib/binpacker/shard_check.rb +127 -0
- data/lib/binpacker/timing.rb +122 -24
- data/lib/binpacker/version.rb +1 -1
- data/lib/binpacker/worker_queue.rb +1 -1
- data/lib/binpacker.rb +16 -14
- data/sig/binpacker/orchestrator.rbs +26 -1
- data/sig/binpacker/report.rbs +2 -1
- data/sig/binpacker/scheduler.rbs +11 -0
- data/sig/binpacker/shard.rbs +13 -0
- data/sig/binpacker/shard_check.rbs +29 -0
- data/sig/binpacker/timing.rbs +6 -0
- data/sig/binpacker/worker_queue.rbs +11 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ae35133b41b6a8423c8caa45b91a4153498099d2f04c790aeada3fb1e4e0d52a
|
|
4
|
+
data.tar.gz: 5e537cf525779a2783db551b1bf86f7525ce34558f16ce8c2eab253712454b8e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ab3dd9a1c6993908e7e646c697d649745d029f15c6a77981ec8b2dd3735afebf92f6194a97e60e2923acc5500fa475e2f7118673cc6c18e2c49ba0a4aa7a7dc1
|
|
7
|
+
data.tar.gz: ad30734e5dbadb596dd753f90ff1163bbc0a12fa60f8d080baad357f9ed2be3ac8a9667d2ed8a52dab2ced6e3e76d2931a1368613976f0a4e2a5af3fd1c4a8da
|
data/exe/binpacker-worker
CHANGED
|
@@ -166,6 +166,16 @@ end.parse!
|
|
|
166
166
|
|
|
167
167
|
wid = options[:id]
|
|
168
168
|
exit_code = 0
|
|
169
|
+
|
|
170
|
+
# Test code must never see the orchestrator's control pipe. A test
|
|
171
|
+
# that reads stdin — an LSP server loop, anything calling gets —
|
|
172
|
+
# would block forever on the pipe the orchestrator keeps open
|
|
173
|
+
# between dynamic batches (or, worse, consume control messages).
|
|
174
|
+
# Keep the pipe on a private handle and point process stdin, which
|
|
175
|
+
# spawned test runners inherit, at /dev/null so readers see EOF.
|
|
176
|
+
control = $stdin.dup
|
|
177
|
+
$stdin.reopen(File::NULL)
|
|
178
|
+
|
|
169
179
|
$stderr.puts "[worker-#{wid}] #{options[:runner]} worker started"
|
|
170
180
|
|
|
171
181
|
loop do
|
|
@@ -174,7 +184,7 @@ loop do
|
|
|
174
184
|
|
|
175
185
|
tests = []
|
|
176
186
|
done_or_eof = false
|
|
177
|
-
|
|
187
|
+
control.each_line do |line|
|
|
178
188
|
data = JSON.parse(line.strip)
|
|
179
189
|
if data["type"] == "done"
|
|
180
190
|
done_or_eof = true
|
|
@@ -186,7 +196,7 @@ loop do
|
|
|
186
196
|
end
|
|
187
197
|
|
|
188
198
|
break if done_or_eof && tests.empty?
|
|
189
|
-
break if tests.empty? && (
|
|
199
|
+
break if tests.empty? && (control.eof? || control.closed?)
|
|
190
200
|
|
|
191
201
|
files = tests.map { |t| t["file"] }.uniq
|
|
192
202
|
$stderr.puts "[worker-#{wid}] batch: #{files.size} files, #{tests.size} entries"
|
data/lib/binpacker/cli.rb
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env ruby
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
-
require
|
|
4
|
+
require 'optparse'
|
|
5
5
|
|
|
6
6
|
module Binpacker
|
|
7
7
|
class CLI
|
|
@@ -21,27 +21,32 @@ module Binpacker
|
|
|
21
21
|
@skill_path = false
|
|
22
22
|
@skill_describe = false
|
|
23
23
|
@skill_name = nil
|
|
24
|
+
# A CI matrix sets env vars far more easily than it rewrites the command a Makefile target runs, so
|
|
25
|
+
# BINPACKER_SHARD is a first-class way in; an explicit --shard still wins over it.
|
|
26
|
+
@shard_spec = ENV.fetch('BINPACKER_SHARD', nil)
|
|
24
27
|
parse!
|
|
25
28
|
end
|
|
26
29
|
|
|
27
30
|
def run
|
|
28
31
|
case @command
|
|
29
|
-
when
|
|
32
|
+
when 'calibrate'
|
|
30
33
|
cmd_calibrate
|
|
31
|
-
when
|
|
34
|
+
when 'run'
|
|
32
35
|
cmd_run
|
|
33
|
-
when
|
|
36
|
+
when 'shards-check'
|
|
37
|
+
cmd_shards_check
|
|
38
|
+
when 'init'
|
|
34
39
|
cmd_init
|
|
35
|
-
when
|
|
40
|
+
when 'skill'
|
|
36
41
|
cmd_skill
|
|
37
|
-
when
|
|
42
|
+
when 'describe'
|
|
38
43
|
cmd_describe
|
|
39
|
-
when
|
|
44
|
+
when '--version', '-v'
|
|
40
45
|
puts "binpacker #{Binpacker::VERSION}"
|
|
41
|
-
when
|
|
46
|
+
when '--help', '-h', nil
|
|
42
47
|
print_help
|
|
43
48
|
else
|
|
44
|
-
|
|
49
|
+
warn "unknown command: #{@command}"
|
|
45
50
|
print_help
|
|
46
51
|
exit 1
|
|
47
52
|
end
|
|
@@ -51,47 +56,51 @@ module Binpacker
|
|
|
51
56
|
|
|
52
57
|
def parse!
|
|
53
58
|
parser = OptionParser.new do |opts|
|
|
54
|
-
opts.banner =
|
|
59
|
+
opts.banner = 'Usage: binpacker <command> [options]'
|
|
55
60
|
|
|
56
|
-
opts.on(
|
|
61
|
+
opts.on('--profile PROFILE', 'Profile name from binpacker.yml') do |v|
|
|
57
62
|
@profile = v
|
|
58
63
|
end
|
|
59
64
|
|
|
60
|
-
opts.on(
|
|
61
|
-
@command ||=
|
|
65
|
+
opts.on('--version', 'Show version') do
|
|
66
|
+
@command ||= '--version'
|
|
62
67
|
end
|
|
63
68
|
|
|
64
|
-
opts.on(
|
|
65
|
-
@command ||=
|
|
69
|
+
opts.on('--help', 'Show help') do
|
|
70
|
+
@command ||= '--help'
|
|
66
71
|
end
|
|
67
72
|
|
|
68
|
-
opts.on(
|
|
73
|
+
opts.on('--quiet', 'Suppress worker output') do
|
|
69
74
|
@quiet = true
|
|
70
75
|
end
|
|
71
76
|
|
|
72
|
-
opts.on(
|
|
77
|
+
opts.on('--incremental', 'Calibrate only tests without timing data') do
|
|
73
78
|
@incremental = true
|
|
74
79
|
end
|
|
75
80
|
|
|
76
|
-
opts.on(
|
|
81
|
+
opts.on('--report PATH', '(run) Write a JSON run report to PATH') do |v|
|
|
77
82
|
@report_path = v
|
|
78
83
|
end
|
|
79
84
|
|
|
80
|
-
opts.on(
|
|
85
|
+
opts.on('--shard K/N', '(run) Run only shard K of N (1-based)') do |v|
|
|
86
|
+
@shard_spec = v
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
opts.on('--list', '(skill) List bundled skills') do
|
|
81
90
|
@skill_list = true
|
|
82
91
|
end
|
|
83
92
|
|
|
84
|
-
opts.on(
|
|
93
|
+
opts.on('--path', '(skill) Print the SKILL.md path for <name>') do
|
|
85
94
|
@skill_path = true
|
|
86
95
|
end
|
|
87
96
|
|
|
88
|
-
opts.on(
|
|
97
|
+
opts.on('--describe', '(skill) Report project state and next skill') do
|
|
89
98
|
@skill_describe = true
|
|
90
99
|
end
|
|
91
100
|
end
|
|
92
101
|
|
|
93
102
|
# Extract passthrough arguments after "--"
|
|
94
|
-
if (split_idx = @args.index(
|
|
103
|
+
if (split_idx = @args.index('--'))
|
|
95
104
|
@passthrough = @args[(split_idx + 1)..] || []
|
|
96
105
|
@args = @args[0...split_idx]
|
|
97
106
|
end
|
|
@@ -100,11 +109,14 @@ module Binpacker
|
|
|
100
109
|
# A positional command wins; otherwise keep any command an option set
|
|
101
110
|
# (e.g. --version), so `binpacker --version` isn't clobbered to nil.
|
|
102
111
|
@command = remaining.shift || @command
|
|
103
|
-
@skill_name = remaining.
|
|
112
|
+
@skill_name = remaining.first
|
|
113
|
+
# `shards-check` takes a list of run-report paths, so the positionals stay available in full rather
|
|
114
|
+
# than being consumed into the single-name slot `skill` uses.
|
|
115
|
+
@shard_reports = remaining
|
|
104
116
|
end
|
|
105
117
|
|
|
106
118
|
def cmd_init
|
|
107
|
-
config_path = Pathname.pwd.join(
|
|
119
|
+
config_path = Pathname.pwd.join('binpacker.yml')
|
|
108
120
|
if config_path.exist?
|
|
109
121
|
puts "binpacker.yml already exists at #{config_path}"
|
|
110
122
|
exit 1
|
|
@@ -112,11 +124,11 @@ module Binpacker
|
|
|
112
124
|
|
|
113
125
|
framework = detect_framework
|
|
114
126
|
unless ProjectState::SUPPORTED_FRAMEWORKS.include?(framework)
|
|
115
|
-
|
|
116
|
-
|
|
127
|
+
warn "Detected #{framework} — binpacker supports rspec and minitest only."
|
|
128
|
+
warn 'No binpacker.yml was created.'
|
|
117
129
|
exit 1
|
|
118
130
|
end
|
|
119
|
-
pattern = framework ==
|
|
131
|
+
pattern = framework == 'minitest' ? 'test/**/*_test.rb' : 'spec/**/*_spec.rb'
|
|
120
132
|
runner = framework
|
|
121
133
|
|
|
122
134
|
yaml = <<~YAML
|
|
@@ -137,15 +149,15 @@ module Binpacker
|
|
|
137
149
|
config_path.write(yaml)
|
|
138
150
|
puts "Created #{config_path}"
|
|
139
151
|
puts "Detected test framework: #{framework}"
|
|
140
|
-
puts
|
|
141
|
-
puts
|
|
142
|
-
puts
|
|
143
|
-
puts
|
|
152
|
+
puts ''
|
|
153
|
+
puts 'Next steps:'
|
|
154
|
+
puts ' 1. binpacker calibrate (seed timing data)'
|
|
155
|
+
puts ' 2. binpacker run (run in parallel)'
|
|
144
156
|
end
|
|
145
157
|
|
|
146
158
|
def cmd_calibrate
|
|
147
159
|
config = Config.new(profile: @profile)
|
|
148
|
-
discovery_klass = config.test_runner ==
|
|
160
|
+
discovery_klass = config.test_runner == 'rspec' ? RSpecDiscovery : MinitestDiscovery
|
|
149
161
|
tests = discovery_klass.new(config).enumerate
|
|
150
162
|
|
|
151
163
|
cal = Calibration.new(config)
|
|
@@ -175,7 +187,7 @@ module Binpacker
|
|
|
175
187
|
if path
|
|
176
188
|
puts path
|
|
177
189
|
else
|
|
178
|
-
|
|
190
|
+
warn "unknown skill: #{@skill_name}"
|
|
179
191
|
exit 1
|
|
180
192
|
end
|
|
181
193
|
elsif @skill_name && !@skill_list
|
|
@@ -187,12 +199,12 @@ module Binpacker
|
|
|
187
199
|
|
|
188
200
|
def cmd_describe
|
|
189
201
|
state = ProjectState.new
|
|
190
|
-
puts
|
|
202
|
+
puts 'binpacker project state:'
|
|
191
203
|
puts " config (binpacker.yml): #{state.config_present? ? 'present' : 'missing'}"
|
|
192
204
|
puts " timing data: #{state.timing_present? ? 'present' : 'missing'}"
|
|
193
205
|
puts " test framework: #{state.framework || 'not detected'}"
|
|
194
206
|
puts " CI wired for binpacker: #{state.ci_wired? ? 'yes' : 'no'}"
|
|
195
|
-
puts
|
|
207
|
+
puts ''
|
|
196
208
|
|
|
197
209
|
unless state.supported_framework?
|
|
198
210
|
puts "binpacker supports rspec and minitest; #{state.framework} is not supported yet."
|
|
@@ -208,45 +220,52 @@ module Binpacker
|
|
|
208
220
|
def list_skills
|
|
209
221
|
skills = Skills.list
|
|
210
222
|
if skills.empty?
|
|
211
|
-
puts
|
|
223
|
+
puts 'No bundled skills found.'
|
|
212
224
|
return
|
|
213
225
|
end
|
|
214
|
-
puts
|
|
226
|
+
puts 'Bundled skills:'
|
|
215
227
|
skills.each { |s| puts " #{s[:name]}" }
|
|
216
|
-
puts
|
|
228
|
+
puts ''
|
|
217
229
|
puts "Print a skill's instructions with `binpacker skill <name>`."
|
|
218
230
|
end
|
|
219
231
|
|
|
220
232
|
def print_skill(name)
|
|
221
233
|
unless Skills.exist?(name)
|
|
222
|
-
|
|
223
|
-
|
|
234
|
+
warn "unknown skill: #{name}"
|
|
235
|
+
warn 'Run `binpacker skill` to list bundled skills.'
|
|
224
236
|
exit 1
|
|
225
237
|
end
|
|
226
238
|
|
|
227
239
|
puts "# Skill: #{name}"
|
|
228
240
|
puts "# Source: #{Skills.path(name)}"
|
|
229
|
-
puts
|
|
241
|
+
puts ''
|
|
230
242
|
puts Skills.body(name)
|
|
231
243
|
end
|
|
232
244
|
|
|
233
245
|
def cmd_run
|
|
234
246
|
config = Config.new(profile: @profile)
|
|
235
247
|
report_path = @report_path || config.report_file
|
|
248
|
+
shard = Shard.parse(@shard_spec)
|
|
236
249
|
orchestrator = Orchestrator.new(
|
|
237
|
-
config, passthrough: @passthrough, quiet: @quiet, report_path: report_path
|
|
250
|
+
config, passthrough: @passthrough, quiet: @quiet, report_path: report_path, shard: shard
|
|
238
251
|
)
|
|
239
252
|
|
|
240
|
-
|
|
253
|
+
start_line = "binpacker starting (#{config.worker_count} workers, profile: #{config.profile}"
|
|
254
|
+
start_line += ", shard: #{shard}" if shard
|
|
255
|
+
puts "#{start_line})"
|
|
241
256
|
result = orchestrator.run
|
|
242
257
|
puts "Run report written to #{report_path}" if report_path
|
|
243
258
|
unit = test_unit_label(config)
|
|
259
|
+
# Named in the summary because a sharded run's totals are its slice's, not the suite's — a reader
|
|
260
|
+
# comparing "10203 examples" with "3401 examples" needs to see which of the two this was.
|
|
261
|
+
scope = shard ? " (shard #{shard})" : ''
|
|
244
262
|
|
|
245
263
|
if result[:passed]
|
|
246
|
-
puts "All #{result[:total]} #{pluralize(result[:total], unit)} passed across
|
|
264
|
+
puts "All #{result[:total]} #{pluralize(result[:total], unit)} passed across " \
|
|
265
|
+
"#{config.worker_count} workers#{scope}."
|
|
247
266
|
exit 0
|
|
248
267
|
elsif result[:empty_filter]
|
|
249
|
-
puts
|
|
268
|
+
puts 'No tests matched the Minitest filter.'
|
|
250
269
|
exit 1
|
|
251
270
|
else
|
|
252
271
|
failed = result[:total] - result[:passed_count]
|
|
@@ -255,8 +274,23 @@ module Binpacker
|
|
|
255
274
|
end
|
|
256
275
|
end
|
|
257
276
|
|
|
277
|
+
# Run after a sharded matrix, over every shard's `--report`. A shard cannot tell "not mine" from "does
|
|
278
|
+
# not exist", so this is the only place a partition that dropped tests can be caught.
|
|
279
|
+
def cmd_shards_check
|
|
280
|
+
result = ShardCheck.call(@shard_reports)
|
|
281
|
+
|
|
282
|
+
if result.ok?
|
|
283
|
+
puts result.summary
|
|
284
|
+
exit 0
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
warn result.summary
|
|
288
|
+
result.problems.each { |problem| warn " #{problem}" }
|
|
289
|
+
exit 1
|
|
290
|
+
end
|
|
291
|
+
|
|
258
292
|
def test_unit_label(config)
|
|
259
|
-
config.test_runner ==
|
|
293
|
+
config.test_runner == 'rspec' ? 'example' : 'test'
|
|
260
294
|
end
|
|
261
295
|
|
|
262
296
|
def pluralize(count, word)
|
|
@@ -269,6 +303,7 @@ module Binpacker
|
|
|
269
303
|
|
|
270
304
|
Commands:
|
|
271
305
|
run Execute tests across worker processes
|
|
306
|
+
shards-check Verify a sharded matrix's run reports cover the whole suite
|
|
272
307
|
calibrate Run tests serially to generate timing data
|
|
273
308
|
init Create binpacker.yml with auto-detected settings
|
|
274
309
|
describe Report project state and recommend the next skill
|
|
@@ -278,6 +313,7 @@ module Binpacker
|
|
|
278
313
|
--profile NAME Select profile from binpacker.yml
|
|
279
314
|
--incremental (calibrate) Measure only tests without timing data
|
|
280
315
|
--report PATH (run) Write a JSON run report to PATH
|
|
316
|
+
--shard K/N (run) Run only shard K of N (1-based; env: BINPACKER_SHARD)
|
|
281
317
|
--list (skill) List bundled skills
|
|
282
318
|
--path (skill) Print the SKILL.md path for <name>
|
|
283
319
|
--describe (skill) Report project state and next skill
|
|
@@ -287,6 +323,8 @@ module Binpacker
|
|
|
287
323
|
binpacker init
|
|
288
324
|
binpacker run --profile ci
|
|
289
325
|
binpacker run -- --tag ~slow
|
|
326
|
+
binpacker run --shard 2/3 --report shard-2.json
|
|
327
|
+
binpacker shards-check shard-*.json
|
|
290
328
|
binpacker calibrate --incremental
|
|
291
329
|
binpacker describe
|
|
292
330
|
binpacker skill binpacker-setup
|
|
@@ -294,7 +332,7 @@ module Binpacker
|
|
|
294
332
|
end
|
|
295
333
|
|
|
296
334
|
def detect_framework
|
|
297
|
-
ProjectState.new.framework ||
|
|
335
|
+
ProjectState.new.framework || 'rspec'
|
|
298
336
|
end
|
|
299
337
|
end
|
|
300
338
|
end
|
|
@@ -2,22 +2,49 @@
|
|
|
2
2
|
|
|
3
3
|
module Binpacker
|
|
4
4
|
class Orchestrator
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
# Dynamic batches halve the queue's remaining predicted weight,
|
|
6
|
+
# floored at a per-run minimum: early batches are large to amortize
|
|
7
|
+
# the per-batch test-runner boot (each batch is a fresh rspec
|
|
8
|
+
# process), tail batches stay small so workers finish together.
|
|
9
|
+
#
|
|
10
|
+
# The floor comes in two regimes (computed by #min_batch_weight in
|
|
11
|
+
# #run and threaded into #drain_batch):
|
|
12
|
+
# - Calibrated: weights are measured seconds, so the floor is a
|
|
13
|
+
# fixed MIN_BATCH_WEIGHT (~30s), an order of magnitude above the
|
|
14
|
+
# ~3s runner boot — boot overhead stays in the noise.
|
|
15
|
+
# - Cold start: weights are filesize KB, not seconds, so a fixed
|
|
16
|
+
# 30 is meaningless. We instead derive a floor that targets
|
|
17
|
+
# ~COLD_START_BATCHES_PER_WORKER batches per worker (below).
|
|
18
|
+
MIN_BATCH_WEIGHT = 30.0
|
|
19
|
+
|
|
20
|
+
# On a pure cold start predicted weights are filesize KB, not
|
|
21
|
+
# seconds, so the 30s MIN_BATCH_WEIGHT floor has no meaning. Instead
|
|
22
|
+
# we size the floor so the total predicted weight divides into about
|
|
23
|
+
# this many batches per worker — matching the ~5 runner boots per
|
|
24
|
+
# worker that gave the best makespan in the PR #12 simulation.
|
|
25
|
+
COLD_START_BATCHES_PER_WORKER = 5
|
|
26
|
+
|
|
27
|
+
def initialize(config, passthrough: [], quiet: false, report_path: nil, shard: nil)
|
|
8
28
|
@config = config
|
|
9
29
|
@passthrough = passthrough
|
|
10
30
|
@quiet = quiet
|
|
11
31
|
@report_path = report_path
|
|
32
|
+
@shard = shard
|
|
12
33
|
end
|
|
13
34
|
|
|
14
35
|
def run
|
|
15
36
|
tests = discover
|
|
16
37
|
timing = Timing.new(@config.timing_file)
|
|
38
|
+
# Weights are computed over the WHOLE suite before slicing, so every shard cuts against the same
|
|
39
|
+
# numbers and the partition they agree on covers every test exactly once.
|
|
17
40
|
timings = timing.load_with_fallback(tests)
|
|
18
41
|
@timings = timings
|
|
19
42
|
|
|
20
|
-
scheduler = Scheduler.for(@config.scheduler[
|
|
43
|
+
scheduler = Scheduler.for(@config.scheduler['algorithm'])
|
|
44
|
+
@discovered_count = tests.size
|
|
45
|
+
tests = @shard.select(tests: tests, timings: timings, scheduler: scheduler) if @shard
|
|
46
|
+
@selected_count = tests.size
|
|
47
|
+
|
|
21
48
|
queues = scheduler.partition(
|
|
22
49
|
tests: tests,
|
|
23
50
|
worker_count: @config.worker_count,
|
|
@@ -28,12 +55,12 @@ module Binpacker
|
|
|
28
55
|
@predicted_loads = queues.map { |q| q.total_weight(timings) }
|
|
29
56
|
|
|
30
57
|
runner_class = TestRunner.for(@config.test_runner)
|
|
31
|
-
workers = queues.map.with_index do |
|
|
58
|
+
workers = queues.map.with_index do |_queue, idx|
|
|
32
59
|
Worker.new(idx, runner_class, passthrough: @passthrough, quiet: @quiet).tap(&:start)
|
|
33
60
|
end
|
|
34
61
|
|
|
35
|
-
if @config.scheduler[
|
|
36
|
-
run_dynamic(workers, queues, timing, tests)
|
|
62
|
+
if @config.scheduler['steal_enabled']
|
|
63
|
+
run_dynamic(workers, queues, timing, tests, min_batch_weight(timing, timings))
|
|
37
64
|
else
|
|
38
65
|
run_static(workers, queues, timing, tests)
|
|
39
66
|
end
|
|
@@ -43,9 +70,9 @@ module Binpacker
|
|
|
43
70
|
|
|
44
71
|
def discover
|
|
45
72
|
case @config.test_runner
|
|
46
|
-
when
|
|
73
|
+
when 'rspec'
|
|
47
74
|
RSpecDiscovery.new(@config).enumerate
|
|
48
|
-
when
|
|
75
|
+
when 'minitest'
|
|
49
76
|
MinitestDiscovery.new(@config).enumerate
|
|
50
77
|
else
|
|
51
78
|
raise ConfigError, "unsupported runner: #{@config.test_runner}"
|
|
@@ -58,7 +85,7 @@ module Binpacker
|
|
|
58
85
|
|
|
59
86
|
workers.zip(queues).each do |worker, queue|
|
|
60
87
|
worker.send_tests(queue.remaining)
|
|
61
|
-
progress.update(worker.id, done: 0, total: queue_totals[worker.id], file: queue.remaining.first&.file ||
|
|
88
|
+
progress.update(worker.id, done: 0, total: queue_totals[worker.id], file: queue.remaining.first&.file || '')
|
|
62
89
|
end
|
|
63
90
|
progress.refresh
|
|
64
91
|
|
|
@@ -81,10 +108,10 @@ module Binpacker
|
|
|
81
108
|
worker_examples[worker.id] = worker.example_count
|
|
82
109
|
worker_passed[worker.id] = worker.passed_count
|
|
83
110
|
worker_time[worker.id] = worker.timings.sum { |t| t[:time] }
|
|
84
|
-
progress.update(worker.id, done: queue_totals[worker.id], total: queue_totals[worker.id], file:
|
|
111
|
+
progress.update(worker.id, done: queue_totals[worker.id], total: queue_totals[worker.id], file: 'done')
|
|
85
112
|
progress.refresh
|
|
86
113
|
rescue WorkerError => e
|
|
87
|
-
|
|
114
|
+
warn "worker #{worker.id} error: #{e.message}"
|
|
88
115
|
all_passed = false
|
|
89
116
|
ensure
|
|
90
117
|
worker.cleanup
|
|
@@ -92,7 +119,7 @@ module Binpacker
|
|
|
92
119
|
|
|
93
120
|
progress.finish
|
|
94
121
|
|
|
95
|
-
worker_stats = workers.map.with_index do |
|
|
122
|
+
worker_stats = workers.map.with_index do |_w, i|
|
|
96
123
|
{
|
|
97
124
|
files: queue_totals[i],
|
|
98
125
|
total_time: worker_time[i],
|
|
@@ -106,11 +133,9 @@ module Binpacker
|
|
|
106
133
|
finalize(timing, all_timings, all_passed, total_examples, passed_examples, tests)
|
|
107
134
|
end
|
|
108
135
|
|
|
109
|
-
def run_dynamic(workers, queues, timing, tests)
|
|
136
|
+
def run_dynamic(workers, queues, timing, tests, floor)
|
|
110
137
|
all_timings = []
|
|
111
138
|
all_passed = true
|
|
112
|
-
total_examples = 0
|
|
113
|
-
passed_examples = 0
|
|
114
139
|
active = []
|
|
115
140
|
|
|
116
141
|
queue_totals = queues.map(&:size)
|
|
@@ -123,25 +148,23 @@ module Binpacker
|
|
|
123
148
|
progress = ProgressDisplay.new(workers.size)
|
|
124
149
|
|
|
125
150
|
workers.zip(queues).each do |worker, queue|
|
|
126
|
-
batch = drain_batch(queue)
|
|
151
|
+
batch = drain_batch(queue, floor)
|
|
127
152
|
if batch.empty?
|
|
128
153
|
worker.signal_done
|
|
129
154
|
worker.collect_results
|
|
130
155
|
all_timings.concat(worker.timings)
|
|
131
156
|
all_passed &&= worker.success?
|
|
132
|
-
total_examples += worker.example_count
|
|
133
|
-
passed_examples += worker.passed_count
|
|
134
157
|
worker_examples[worker.id] = worker.example_count
|
|
135
158
|
worker_passed[worker.id] = worker.passed_count
|
|
136
159
|
worker.cleanup
|
|
137
160
|
worker_done[worker.id] = queue_totals[worker.id]
|
|
138
|
-
progress.update(worker.id, done: worker_done[worker.id], total: queue_totals[worker.id], file:
|
|
161
|
+
progress.update(worker.id, done: worker_done[worker.id], total: queue_totals[worker.id], file: 'done')
|
|
139
162
|
else
|
|
140
163
|
worker.send_tests(batch)
|
|
141
164
|
worker.batch_done
|
|
142
165
|
active << worker
|
|
143
166
|
batch_sizes[worker.id] = batch.size
|
|
144
|
-
current_file = batch.first&.file ||
|
|
167
|
+
current_file = batch.first&.file || ''
|
|
145
168
|
progress.update(worker.id, done: 0, total: queue_totals[worker.id], file: current_file)
|
|
146
169
|
end
|
|
147
170
|
end
|
|
@@ -149,33 +172,38 @@ module Binpacker
|
|
|
149
172
|
until active.empty?
|
|
150
173
|
ready = active.find { |w| w.wait_for_batch }
|
|
151
174
|
unless ready
|
|
152
|
-
active.reject! { |w|
|
|
175
|
+
active.reject! { |w| %i[crashed error].include?(w.status) }
|
|
153
176
|
sleep 0.1
|
|
154
177
|
next
|
|
155
178
|
end
|
|
156
179
|
|
|
157
180
|
begin
|
|
158
181
|
all_passed &&= ready.success?
|
|
159
|
-
total_examples += ready.example_count
|
|
160
|
-
passed_examples += ready.passed_count
|
|
161
182
|
|
|
162
183
|
worker_done[ready.id] += batch_sizes[ready.id]
|
|
184
|
+
# Worker#example_count accumulates across batches, so these
|
|
185
|
+
# are running totals per worker; the grand totals are summed
|
|
186
|
+
# once after the loop, never added per batch.
|
|
163
187
|
worker_examples[ready.id] = ready.example_count
|
|
164
188
|
worker_passed[ready.id] = ready.passed_count
|
|
165
189
|
|
|
166
190
|
own_queue = queues[ready.id]
|
|
167
|
-
next_batch = drain_batch(own_queue)
|
|
191
|
+
next_batch = drain_batch(own_queue, floor)
|
|
168
192
|
|
|
169
193
|
if next_batch.empty?
|
|
170
|
-
donor = queues.reject(&:empty?).max_by(
|
|
171
|
-
|
|
194
|
+
donor = queues.reject(&:empty?).max_by { |q| q.total_weight(@timings) }
|
|
195
|
+
if donor
|
|
196
|
+
next_batch = drain_batch(donor, floor)
|
|
197
|
+
queue_totals[ready.id] += next_batch.size
|
|
198
|
+
queue_totals[donor.worker_id] -= next_batch.size
|
|
199
|
+
end
|
|
172
200
|
end
|
|
173
201
|
|
|
174
202
|
if next_batch.any?
|
|
175
203
|
ready.send_tests(next_batch)
|
|
176
204
|
ready.batch_done
|
|
177
205
|
batch_sizes[ready.id] = next_batch.size
|
|
178
|
-
current_file = next_batch.first&.file ||
|
|
206
|
+
current_file = next_batch.first&.file || ''
|
|
179
207
|
progress.update(ready.id, done: worker_done[ready.id], total: queue_totals[ready.id], file: current_file)
|
|
180
208
|
progress.refresh
|
|
181
209
|
else
|
|
@@ -183,11 +211,11 @@ module Binpacker
|
|
|
183
211
|
all_timings.concat(ready.timings)
|
|
184
212
|
active.delete(ready)
|
|
185
213
|
worker_done[ready.id] = queue_totals[ready.id]
|
|
186
|
-
progress.update(ready.id, done: queue_totals[ready.id], total: queue_totals[ready.id], file:
|
|
214
|
+
progress.update(ready.id, done: queue_totals[ready.id], total: queue_totals[ready.id], file: 'done')
|
|
187
215
|
progress.refresh
|
|
188
216
|
end
|
|
189
217
|
rescue WorkerError => e
|
|
190
|
-
|
|
218
|
+
warn "worker #{ready.id} error: #{e.message}"
|
|
191
219
|
all_passed = false
|
|
192
220
|
active.delete(ready)
|
|
193
221
|
end
|
|
@@ -195,6 +223,9 @@ module Binpacker
|
|
|
195
223
|
|
|
196
224
|
progress.finish
|
|
197
225
|
|
|
226
|
+
total_examples = worker_examples.sum
|
|
227
|
+
passed_examples = worker_passed.sum
|
|
228
|
+
|
|
198
229
|
worker_stats = workers.map.with_index do |w, i|
|
|
199
230
|
tw = w.timings.sum { |t| t[:time] }
|
|
200
231
|
{
|
|
@@ -216,27 +247,54 @@ module Binpacker
|
|
|
216
247
|
|
|
217
248
|
Report.new(
|
|
218
249
|
profile: @config.profile,
|
|
219
|
-
algorithm: @config.scheduler[
|
|
250
|
+
algorithm: @config.scheduler['algorithm'],
|
|
220
251
|
predicted_loads: @predicted_loads,
|
|
221
252
|
worker_stats: worker_stats,
|
|
222
253
|
all_timings: all_timings,
|
|
223
|
-
timings: @timings
|
|
254
|
+
timings: @timings,
|
|
255
|
+
shard: @shard,
|
|
256
|
+
discovered: @discovered_count,
|
|
257
|
+
selected: @selected_count
|
|
224
258
|
).write(@report_path)
|
|
225
259
|
end
|
|
226
260
|
|
|
227
|
-
|
|
261
|
+
# Per-run batch-weight floor. Calibrated runs use the fixed
|
|
262
|
+
# seconds-scale MIN_BATCH_WEIGHT. On a cold start weights are KB, so
|
|
263
|
+
# that floor is meaningless; derive one that splits the total
|
|
264
|
+
# predicted weight into ~COLD_START_BATCHES_PER_WORKER batches per
|
|
265
|
+
# worker, falling back to MIN_BATCH_WEIGHT when there is nothing to
|
|
266
|
+
# divide (zero total weight or zero workers).
|
|
267
|
+
def min_batch_weight(timing, timings)
|
|
268
|
+
return MIN_BATCH_WEIGHT if timing.calibrated?
|
|
269
|
+
|
|
270
|
+
total = timings.values.sum(0.0)
|
|
271
|
+
workers = @config.worker_count
|
|
272
|
+
return MIN_BATCH_WEIGHT if total.zero? || workers.zero?
|
|
273
|
+
|
|
274
|
+
total / (workers * COLD_START_BATCHES_PER_WORKER)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# Drains the next batch off +queue+: enough tests to halve its
|
|
278
|
+
# remaining predicted weight, but never less than +floor+ (see
|
|
279
|
+
# #min_batch_weight) and never fewer than one test.
|
|
280
|
+
def drain_batch(queue, floor)
|
|
228
281
|
return [] if queue.nil? || queue.empty?
|
|
282
|
+
|
|
283
|
+
target = [queue.total_weight(@timings) / 2.0, floor].max
|
|
229
284
|
batch = []
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
break unless test
|
|
285
|
+
weight = 0.0
|
|
286
|
+
while (batch.empty? || weight < target) && (test = queue.pop)
|
|
233
287
|
batch << test
|
|
288
|
+
weight += @timings.fetch(test.key, Timing::DEFAULT_WEIGHT)
|
|
234
289
|
end
|
|
235
290
|
batch
|
|
236
291
|
end
|
|
237
292
|
|
|
238
293
|
def finalize(timing, all_timings, all_passed, total_examples, passed_examples, tests)
|
|
239
|
-
|
|
294
|
+
unless all_timings.empty?
|
|
295
|
+
timing.append_all(all_timings)
|
|
296
|
+
timing.compact!
|
|
297
|
+
end
|
|
240
298
|
empty_filter = minitest_empty_filter?(tests, total_examples)
|
|
241
299
|
all_passed = false if empty_filter
|
|
242
300
|
|
|
@@ -250,7 +308,7 @@ module Binpacker
|
|
|
250
308
|
end
|
|
251
309
|
|
|
252
310
|
def minitest_empty_filter?(tests, total_examples)
|
|
253
|
-
return false unless @config.test_runner ==
|
|
311
|
+
return false unless @config.test_runner == 'minitest'
|
|
254
312
|
return false unless tests.any?
|
|
255
313
|
return false unless total_examples.zero?
|
|
256
314
|
|
|
@@ -260,8 +318,8 @@ module Binpacker
|
|
|
260
318
|
def minitest_include_filter?
|
|
261
319
|
@passthrough.any? do |arg|
|
|
262
320
|
%w[--name --include -n -i].include?(arg) ||
|
|
263
|
-
arg.start_with?(
|
|
264
|
-
(arg.start_with?(
|
|
321
|
+
arg.start_with?('--name=', '--include=') ||
|
|
322
|
+
(arg.start_with?('-n', '-i') && arg.length > 2)
|
|
265
323
|
end
|
|
266
324
|
end
|
|
267
325
|
end
|