binpacker 0.2.0 → 0.4.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/calibration.rb +20 -8
- data/lib/binpacker/cli.rb +132 -10
- data/lib/binpacker/config.rb +2 -1
- data/lib/binpacker/orchestrator.rb +106 -38
- data/lib/binpacker/progress.rb +20 -16
- data/lib/binpacker/project_state.rb +82 -0
- data/lib/binpacker/report.rb +102 -0
- data/lib/binpacker/skills.rb +36 -0
- data/lib/binpacker/test_discovery.rb +7 -1
- data/lib/binpacker/timing.rb +125 -22
- data/lib/binpacker/version.rb +1 -1
- data/lib/binpacker/worker_queue.rb +1 -1
- data/lib/binpacker.rb +3 -0
- data/sig/binpacker/calibration.rbs +6 -0
- data/sig/binpacker/cli.rbs +7 -0
- data/sig/binpacker/config.rbs +13 -0
- data/sig/binpacker/orchestrator.rbs +28 -0
- data/sig/binpacker/progress.rbs +5 -0
- data/sig/binpacker/project_state.rbs +16 -0
- data/sig/binpacker/report.rbs +10 -0
- data/sig/binpacker/scheduler.rbs +5 -0
- data/sig/binpacker/skills.rbs +10 -0
- data/sig/binpacker/test_discovery.rbs +15 -0
- data/sig/binpacker/test_runner.rbs +13 -0
- data/sig/binpacker/timing.rbs +17 -0
- data/sig/binpacker/worker.rbs +12 -0
- data/sig/binpacker/worker_queue.rbs +8 -0
- data/skills/binpacker-improve/SKILL.md +52 -0
- data/skills/binpacker-setup/SKILL.md +76 -0
- metadata +24 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 98842f3601fe059ff372b62a214ee73e5e96d0b2c01cf5b651063f04e3f00184
|
|
4
|
+
data.tar.gz: 94e73ac26474bf1da1942509f35618e8576d230b685ee69fe09579d4b9387898
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6a983e121b41c7a9e62dad700ee5541813b84c2553e7fc479c3dbedf996358669a9508d2fd7f3eb8df21831c583421ee91382a453ac73a60beead9bf03da0675
|
|
7
|
+
data.tar.gz: 657ac41276da7094a5ea54d53936a9237b1b3139f53b741c954427b57f2d781dca56df91746972e33e93f49edb7a93a2155d5b92561653d672f87ca73373669a
|
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"
|
|
@@ -13,10 +13,13 @@ module Binpacker
|
|
|
13
13
|
@timing = Timing.new(config.timing_file)
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
# Runs each Test serially and appends its measured Weight to the Timing file.
|
|
17
|
+
# With incremental: true, Tests that already have a measured Weight are skipped.
|
|
18
|
+
def run(tests, incremental: false)
|
|
19
|
+
targets = incremental ? unmeasured(tests) : tests
|
|
17
20
|
results = []
|
|
18
21
|
|
|
19
|
-
|
|
22
|
+
targets.each do |test|
|
|
20
23
|
elapsed = run_single(test)
|
|
21
24
|
results << { file: test.file, name: test.name, time: elapsed }
|
|
22
25
|
end
|
|
@@ -27,21 +30,30 @@ module Binpacker
|
|
|
27
30
|
|
|
28
31
|
private
|
|
29
32
|
|
|
33
|
+
def unmeasured(tests)
|
|
34
|
+
measured = @timing.load_raw
|
|
35
|
+
tests.reject { |test| measured.key?([@timing.normalize_path(test.file), test.name]) }
|
|
36
|
+
end
|
|
37
|
+
|
|
30
38
|
def run_single(test)
|
|
31
39
|
outfile = Tempfile.new("binpacker-cal")
|
|
32
40
|
outfile.close
|
|
33
41
|
|
|
34
42
|
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
35
43
|
|
|
44
|
+
# At file granularity the Test's name is its file path (see Test
|
|
45
|
+
# discovery), so there is no single example/method to filter — run the
|
|
46
|
+
# whole file. A per-test filter would match nothing and measure only
|
|
47
|
+
# load time.
|
|
48
|
+
file_unit = test.name == test.file
|
|
49
|
+
|
|
36
50
|
case @config.test_runner
|
|
37
51
|
when "rspec"
|
|
38
|
-
cmd = [
|
|
39
|
-
|
|
40
|
-
"--example", test.name,
|
|
41
|
-
"--format", "json", "--out", outfile.path
|
|
42
|
-
]
|
|
52
|
+
cmd = ["rspec", test.file, "--format", "json", "--out", outfile.path]
|
|
53
|
+
cmd.push("--example", test.name) unless file_unit
|
|
43
54
|
when "minitest"
|
|
44
|
-
cmd = ["ruby", "-Ilib:test", test.file
|
|
55
|
+
cmd = ["ruby", "-Ilib:test", test.file]
|
|
56
|
+
cmd.push("--name", "/^#{Regexp.escape(test.name)}$/") unless file_unit
|
|
45
57
|
else
|
|
46
58
|
raise ConfigError, "unsupported runner for calibration: #{@config.test_runner}"
|
|
47
59
|
end
|
data/lib/binpacker/cli.rb
CHANGED
|
@@ -15,6 +15,12 @@ module Binpacker
|
|
|
15
15
|
@profile = nil
|
|
16
16
|
@passthrough = []
|
|
17
17
|
@quiet = false
|
|
18
|
+
@incremental = false
|
|
19
|
+
@report_path = nil
|
|
20
|
+
@skill_list = false
|
|
21
|
+
@skill_path = false
|
|
22
|
+
@skill_describe = false
|
|
23
|
+
@skill_name = nil
|
|
18
24
|
parse!
|
|
19
25
|
end
|
|
20
26
|
|
|
@@ -26,6 +32,10 @@ module Binpacker
|
|
|
26
32
|
cmd_run
|
|
27
33
|
when "init"
|
|
28
34
|
cmd_init
|
|
35
|
+
when "skill"
|
|
36
|
+
cmd_skill
|
|
37
|
+
when "describe"
|
|
38
|
+
cmd_describe
|
|
29
39
|
when "--version", "-v"
|
|
30
40
|
puts "binpacker #{Binpacker::VERSION}"
|
|
31
41
|
when "--help", "-h", nil
|
|
@@ -58,6 +68,26 @@ module Binpacker
|
|
|
58
68
|
opts.on("--quiet", "Suppress worker output") do
|
|
59
69
|
@quiet = true
|
|
60
70
|
end
|
|
71
|
+
|
|
72
|
+
opts.on("--incremental", "Calibrate only tests without timing data") do
|
|
73
|
+
@incremental = true
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
opts.on("--report PATH", "(run) Write a JSON run report to PATH") do |v|
|
|
77
|
+
@report_path = v
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
opts.on("--list", "(skill) List bundled skills") do
|
|
81
|
+
@skill_list = true
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
opts.on("--path", "(skill) Print the SKILL.md path for <name>") do
|
|
85
|
+
@skill_path = true
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
opts.on("--describe", "(skill) Report project state and next skill") do
|
|
89
|
+
@skill_describe = true
|
|
90
|
+
end
|
|
61
91
|
end
|
|
62
92
|
|
|
63
93
|
# Extract passthrough arguments after "--"
|
|
@@ -67,7 +97,10 @@ module Binpacker
|
|
|
67
97
|
end
|
|
68
98
|
|
|
69
99
|
remaining = parser.parse(@args)
|
|
70
|
-
|
|
100
|
+
# A positional command wins; otherwise keep any command an option set
|
|
101
|
+
# (e.g. --version), so `binpacker --version` isn't clobbered to nil.
|
|
102
|
+
@command = remaining.shift || @command
|
|
103
|
+
@skill_name = remaining.shift
|
|
71
104
|
end
|
|
72
105
|
|
|
73
106
|
def cmd_init
|
|
@@ -78,6 +111,11 @@ module Binpacker
|
|
|
78
111
|
end
|
|
79
112
|
|
|
80
113
|
framework = detect_framework
|
|
114
|
+
unless ProjectState::SUPPORTED_FRAMEWORKS.include?(framework)
|
|
115
|
+
$stderr.puts "Detected #{framework} — binpacker supports rspec and minitest only."
|
|
116
|
+
$stderr.puts "No binpacker.yml was created."
|
|
117
|
+
exit 1
|
|
118
|
+
end
|
|
81
119
|
pattern = framework == "minitest" ? "test/**/*_test.rb" : "spec/**/*_spec.rb"
|
|
82
120
|
runner = framework
|
|
83
121
|
|
|
@@ -89,7 +127,7 @@ module Binpacker
|
|
|
89
127
|
timing_file: binpacker.timings
|
|
90
128
|
test_pattern: "#{pattern}"
|
|
91
129
|
scheduler:
|
|
92
|
-
algorithm:
|
|
130
|
+
algorithm: multifit
|
|
93
131
|
steal_enabled: true
|
|
94
132
|
ci:
|
|
95
133
|
extends: default
|
|
@@ -110,21 +148,98 @@ module Binpacker
|
|
|
110
148
|
discovery_klass = config.test_runner == "rspec" ? RSpecDiscovery : MinitestDiscovery
|
|
111
149
|
tests = discovery_klass.new(config).enumerate
|
|
112
150
|
|
|
113
|
-
puts "Calibrating #{tests.size} tests..."
|
|
114
151
|
cal = Calibration.new(config)
|
|
115
|
-
|
|
152
|
+
|
|
153
|
+
if @incremental
|
|
154
|
+
puts "Calibrating (incremental) #{tests.size} tests..."
|
|
155
|
+
else
|
|
156
|
+
puts "Calibrating #{tests.size} tests..."
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
timings = cal.run(tests, incremental: @incremental)
|
|
116
160
|
|
|
117
161
|
total = timings.sum { |t| t[:time] }
|
|
118
|
-
|
|
162
|
+
measured = timings.size
|
|
163
|
+
skipped = tests.size - measured
|
|
164
|
+
summary = "Calibration complete: #{measured} #{pluralize(measured, 'test')} in #{total.round(2)}s"
|
|
165
|
+
summary += " (#{skipped} skipped, already measured)" if @incremental && skipped.positive?
|
|
166
|
+
puts summary
|
|
119
167
|
puts "Timing data written to #{config.timing_file}"
|
|
120
168
|
end
|
|
121
169
|
|
|
170
|
+
def cmd_skill
|
|
171
|
+
return cmd_describe if @skill_describe
|
|
172
|
+
|
|
173
|
+
if @skill_path
|
|
174
|
+
path = Skills.path(@skill_name)
|
|
175
|
+
if path
|
|
176
|
+
puts path
|
|
177
|
+
else
|
|
178
|
+
$stderr.puts "unknown skill: #{@skill_name}"
|
|
179
|
+
exit 1
|
|
180
|
+
end
|
|
181
|
+
elsif @skill_name && !@skill_list
|
|
182
|
+
print_skill(@skill_name)
|
|
183
|
+
else
|
|
184
|
+
list_skills
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def cmd_describe
|
|
189
|
+
state = ProjectState.new
|
|
190
|
+
puts "binpacker project state:"
|
|
191
|
+
puts " config (binpacker.yml): #{state.config_present? ? 'present' : 'missing'}"
|
|
192
|
+
puts " timing data: #{state.timing_present? ? 'present' : 'missing'}"
|
|
193
|
+
puts " test framework: #{state.framework || 'not detected'}"
|
|
194
|
+
puts " CI wired for binpacker: #{state.ci_wired? ? 'yes' : 'no'}"
|
|
195
|
+
puts ""
|
|
196
|
+
|
|
197
|
+
unless state.supported_framework?
|
|
198
|
+
puts "binpacker supports rspec and minitest; #{state.framework} is not supported yet."
|
|
199
|
+
puts "Setup cannot proceed against a #{state.framework} suite."
|
|
200
|
+
return
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
rec = state.recommendation
|
|
204
|
+
puts "Recommended next skill: #{rec}"
|
|
205
|
+
puts "Run `binpacker skill #{rec}` to load its instructions."
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def list_skills
|
|
209
|
+
skills = Skills.list
|
|
210
|
+
if skills.empty?
|
|
211
|
+
puts "No bundled skills found."
|
|
212
|
+
return
|
|
213
|
+
end
|
|
214
|
+
puts "Bundled skills:"
|
|
215
|
+
skills.each { |s| puts " #{s[:name]}" }
|
|
216
|
+
puts ""
|
|
217
|
+
puts "Print a skill's instructions with `binpacker skill <name>`."
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def print_skill(name)
|
|
221
|
+
unless Skills.exist?(name)
|
|
222
|
+
$stderr.puts "unknown skill: #{name}"
|
|
223
|
+
$stderr.puts "Run `binpacker skill` to list bundled skills."
|
|
224
|
+
exit 1
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
puts "# Skill: #{name}"
|
|
228
|
+
puts "# Source: #{Skills.path(name)}"
|
|
229
|
+
puts ""
|
|
230
|
+
puts Skills.body(name)
|
|
231
|
+
end
|
|
232
|
+
|
|
122
233
|
def cmd_run
|
|
123
234
|
config = Config.new(profile: @profile)
|
|
124
|
-
|
|
235
|
+
report_path = @report_path || config.report_file
|
|
236
|
+
orchestrator = Orchestrator.new(
|
|
237
|
+
config, passthrough: @passthrough, quiet: @quiet, report_path: report_path
|
|
238
|
+
)
|
|
125
239
|
|
|
126
240
|
puts "binpacker starting (#{config.worker_count} workers, profile: #{config.profile})"
|
|
127
241
|
result = orchestrator.run
|
|
242
|
+
puts "Run report written to #{report_path}" if report_path
|
|
128
243
|
unit = test_unit_label(config)
|
|
129
244
|
|
|
130
245
|
if result[:passed]
|
|
@@ -156,23 +271,30 @@ module Binpacker
|
|
|
156
271
|
run Execute tests across worker processes
|
|
157
272
|
calibrate Run tests serially to generate timing data
|
|
158
273
|
init Create binpacker.yml with auto-detected settings
|
|
274
|
+
describe Report project state and recommend the next skill
|
|
275
|
+
skill List or print bundled agent skills
|
|
159
276
|
|
|
160
277
|
Options:
|
|
161
278
|
--profile NAME Select profile from binpacker.yml
|
|
279
|
+
--incremental (calibrate) Measure only tests without timing data
|
|
280
|
+
--report PATH (run) Write a JSON run report to PATH
|
|
281
|
+
--list (skill) List bundled skills
|
|
282
|
+
--path (skill) Print the SKILL.md path for <name>
|
|
283
|
+
--describe (skill) Report project state and next skill
|
|
162
284
|
--help Show this message
|
|
163
285
|
|
|
164
286
|
Examples:
|
|
165
287
|
binpacker init
|
|
166
288
|
binpacker run --profile ci
|
|
167
289
|
binpacker run -- --tag ~slow
|
|
168
|
-
binpacker calibrate
|
|
290
|
+
binpacker calibrate --incremental
|
|
291
|
+
binpacker describe
|
|
292
|
+
binpacker skill binpacker-setup
|
|
169
293
|
HELP
|
|
170
294
|
end
|
|
171
295
|
|
|
172
296
|
def detect_framework
|
|
173
|
-
|
|
174
|
-
return "minitest" if Dir.glob("test*/**/test_*.rb").any?
|
|
175
|
-
"rspec"
|
|
297
|
+
ProjectState.new.framework || "rspec"
|
|
176
298
|
end
|
|
177
299
|
end
|
|
178
300
|
end
|
data/lib/binpacker/config.rb
CHANGED
|
@@ -14,10 +14,11 @@ module Binpacker
|
|
|
14
14
|
"timing_file" => "binpacker.timings",
|
|
15
15
|
"test_pattern" => "spec/**/*_spec.rb",
|
|
16
16
|
"test_exclude" => [],
|
|
17
|
+
"report_file" => nil,
|
|
17
18
|
"scheduler" => {
|
|
18
19
|
"strategy" => "static",
|
|
19
20
|
"steal_enabled" => false,
|
|
20
|
-
"algorithm" => "
|
|
21
|
+
"algorithm" => "multifit"
|
|
21
22
|
}
|
|
22
23
|
}.freeze
|
|
23
24
|
|
|
@@ -2,33 +2,58 @@
|
|
|
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)
|
|
8
28
|
@config = config
|
|
9
29
|
@passthrough = passthrough
|
|
10
30
|
@quiet = quiet
|
|
31
|
+
@report_path = report_path
|
|
11
32
|
end
|
|
12
33
|
|
|
13
34
|
def run
|
|
14
35
|
tests = discover
|
|
15
36
|
timing = Timing.new(@config.timing_file)
|
|
16
37
|
timings = timing.load_with_fallback(tests)
|
|
38
|
+
@timings = timings
|
|
17
39
|
|
|
18
|
-
scheduler = Scheduler.for(@config.scheduler[
|
|
40
|
+
scheduler = Scheduler.for(@config.scheduler['algorithm'])
|
|
19
41
|
queues = scheduler.partition(
|
|
20
42
|
tests: tests,
|
|
21
43
|
worker_count: @config.worker_count,
|
|
22
44
|
timings: timings
|
|
23
45
|
)
|
|
24
46
|
|
|
47
|
+
# Capture predicted per-Worker loads before execution drains the queues.
|
|
48
|
+
@predicted_loads = queues.map { |q| q.total_weight(timings) }
|
|
49
|
+
|
|
25
50
|
runner_class = TestRunner.for(@config.test_runner)
|
|
26
|
-
workers = queues.map.with_index do |
|
|
51
|
+
workers = queues.map.with_index do |_queue, idx|
|
|
27
52
|
Worker.new(idx, runner_class, passthrough: @passthrough, quiet: @quiet).tap(&:start)
|
|
28
53
|
end
|
|
29
54
|
|
|
30
|
-
if @config.scheduler[
|
|
31
|
-
run_dynamic(workers, queues, timing, tests)
|
|
55
|
+
if @config.scheduler['steal_enabled']
|
|
56
|
+
run_dynamic(workers, queues, timing, tests, min_batch_weight(timing, timings))
|
|
32
57
|
else
|
|
33
58
|
run_static(workers, queues, timing, tests)
|
|
34
59
|
end
|
|
@@ -38,9 +63,9 @@ module Binpacker
|
|
|
38
63
|
|
|
39
64
|
def discover
|
|
40
65
|
case @config.test_runner
|
|
41
|
-
when
|
|
66
|
+
when 'rspec'
|
|
42
67
|
RSpecDiscovery.new(@config).enumerate
|
|
43
|
-
when
|
|
68
|
+
when 'minitest'
|
|
44
69
|
MinitestDiscovery.new(@config).enumerate
|
|
45
70
|
else
|
|
46
71
|
raise ConfigError, "unsupported runner: #{@config.test_runner}"
|
|
@@ -53,7 +78,7 @@ module Binpacker
|
|
|
53
78
|
|
|
54
79
|
workers.zip(queues).each do |worker, queue|
|
|
55
80
|
worker.send_tests(queue.remaining)
|
|
56
|
-
progress.update(worker.id, done: 0, total: queue_totals[worker.id], file: queue.remaining.first&.file ||
|
|
81
|
+
progress.update(worker.id, done: 0, total: queue_totals[worker.id], file: queue.remaining.first&.file || '')
|
|
57
82
|
end
|
|
58
83
|
progress.refresh
|
|
59
84
|
|
|
@@ -76,10 +101,10 @@ module Binpacker
|
|
|
76
101
|
worker_examples[worker.id] = worker.example_count
|
|
77
102
|
worker_passed[worker.id] = worker.passed_count
|
|
78
103
|
worker_time[worker.id] = worker.timings.sum { |t| t[:time] }
|
|
79
|
-
progress.update(worker.id, done: queue_totals[worker.id], total: queue_totals[worker.id], file:
|
|
104
|
+
progress.update(worker.id, done: queue_totals[worker.id], total: queue_totals[worker.id], file: 'done')
|
|
80
105
|
progress.refresh
|
|
81
106
|
rescue WorkerError => e
|
|
82
|
-
|
|
107
|
+
warn "worker #{worker.id} error: #{e.message}"
|
|
83
108
|
all_passed = false
|
|
84
109
|
ensure
|
|
85
110
|
worker.cleanup
|
|
@@ -87,7 +112,7 @@ module Binpacker
|
|
|
87
112
|
|
|
88
113
|
progress.finish
|
|
89
114
|
|
|
90
|
-
worker_stats = workers.map.with_index do |
|
|
115
|
+
worker_stats = workers.map.with_index do |_w, i|
|
|
91
116
|
{
|
|
92
117
|
files: queue_totals[i],
|
|
93
118
|
total_time: worker_time[i],
|
|
@@ -96,15 +121,14 @@ module Binpacker
|
|
|
96
121
|
}
|
|
97
122
|
end
|
|
98
123
|
progress.summary(worker_stats)
|
|
124
|
+
write_report(worker_stats, all_timings)
|
|
99
125
|
|
|
100
126
|
finalize(timing, all_timings, all_passed, total_examples, passed_examples, tests)
|
|
101
127
|
end
|
|
102
128
|
|
|
103
|
-
def run_dynamic(workers, queues, timing, tests)
|
|
129
|
+
def run_dynamic(workers, queues, timing, tests, floor)
|
|
104
130
|
all_timings = []
|
|
105
131
|
all_passed = true
|
|
106
|
-
total_examples = 0
|
|
107
|
-
passed_examples = 0
|
|
108
132
|
active = []
|
|
109
133
|
|
|
110
134
|
queue_totals = queues.map(&:size)
|
|
@@ -117,25 +141,23 @@ module Binpacker
|
|
|
117
141
|
progress = ProgressDisplay.new(workers.size)
|
|
118
142
|
|
|
119
143
|
workers.zip(queues).each do |worker, queue|
|
|
120
|
-
batch = drain_batch(queue)
|
|
144
|
+
batch = drain_batch(queue, floor)
|
|
121
145
|
if batch.empty?
|
|
122
146
|
worker.signal_done
|
|
123
147
|
worker.collect_results
|
|
124
148
|
all_timings.concat(worker.timings)
|
|
125
149
|
all_passed &&= worker.success?
|
|
126
|
-
total_examples += worker.example_count
|
|
127
|
-
passed_examples += worker.passed_count
|
|
128
150
|
worker_examples[worker.id] = worker.example_count
|
|
129
151
|
worker_passed[worker.id] = worker.passed_count
|
|
130
152
|
worker.cleanup
|
|
131
153
|
worker_done[worker.id] = queue_totals[worker.id]
|
|
132
|
-
progress.update(worker.id, done: worker_done[worker.id], total: queue_totals[worker.id], file:
|
|
154
|
+
progress.update(worker.id, done: worker_done[worker.id], total: queue_totals[worker.id], file: 'done')
|
|
133
155
|
else
|
|
134
156
|
worker.send_tests(batch)
|
|
135
157
|
worker.batch_done
|
|
136
158
|
active << worker
|
|
137
159
|
batch_sizes[worker.id] = batch.size
|
|
138
|
-
current_file = batch.first&.file ||
|
|
160
|
+
current_file = batch.first&.file || ''
|
|
139
161
|
progress.update(worker.id, done: 0, total: queue_totals[worker.id], file: current_file)
|
|
140
162
|
end
|
|
141
163
|
end
|
|
@@ -143,33 +165,38 @@ module Binpacker
|
|
|
143
165
|
until active.empty?
|
|
144
166
|
ready = active.find { |w| w.wait_for_batch }
|
|
145
167
|
unless ready
|
|
146
|
-
active.reject! { |w|
|
|
168
|
+
active.reject! { |w| %i[crashed error].include?(w.status) }
|
|
147
169
|
sleep 0.1
|
|
148
170
|
next
|
|
149
171
|
end
|
|
150
172
|
|
|
151
173
|
begin
|
|
152
174
|
all_passed &&= ready.success?
|
|
153
|
-
total_examples += ready.example_count
|
|
154
|
-
passed_examples += ready.passed_count
|
|
155
175
|
|
|
156
176
|
worker_done[ready.id] += batch_sizes[ready.id]
|
|
177
|
+
# Worker#example_count accumulates across batches, so these
|
|
178
|
+
# are running totals per worker; the grand totals are summed
|
|
179
|
+
# once after the loop, never added per batch.
|
|
157
180
|
worker_examples[ready.id] = ready.example_count
|
|
158
181
|
worker_passed[ready.id] = ready.passed_count
|
|
159
182
|
|
|
160
183
|
own_queue = queues[ready.id]
|
|
161
|
-
next_batch = drain_batch(own_queue)
|
|
184
|
+
next_batch = drain_batch(own_queue, floor)
|
|
162
185
|
|
|
163
186
|
if next_batch.empty?
|
|
164
|
-
donor = queues.reject(&:empty?).max_by(
|
|
165
|
-
|
|
187
|
+
donor = queues.reject(&:empty?).max_by { |q| q.total_weight(@timings) }
|
|
188
|
+
if donor
|
|
189
|
+
next_batch = drain_batch(donor, floor)
|
|
190
|
+
queue_totals[ready.id] += next_batch.size
|
|
191
|
+
queue_totals[donor.worker_id] -= next_batch.size
|
|
192
|
+
end
|
|
166
193
|
end
|
|
167
194
|
|
|
168
195
|
if next_batch.any?
|
|
169
196
|
ready.send_tests(next_batch)
|
|
170
197
|
ready.batch_done
|
|
171
198
|
batch_sizes[ready.id] = next_batch.size
|
|
172
|
-
current_file = next_batch.first&.file ||
|
|
199
|
+
current_file = next_batch.first&.file || ''
|
|
173
200
|
progress.update(ready.id, done: worker_done[ready.id], total: queue_totals[ready.id], file: current_file)
|
|
174
201
|
progress.refresh
|
|
175
202
|
else
|
|
@@ -177,11 +204,11 @@ module Binpacker
|
|
|
177
204
|
all_timings.concat(ready.timings)
|
|
178
205
|
active.delete(ready)
|
|
179
206
|
worker_done[ready.id] = queue_totals[ready.id]
|
|
180
|
-
progress.update(ready.id, done: queue_totals[ready.id], total: queue_totals[ready.id], file:
|
|
207
|
+
progress.update(ready.id, done: queue_totals[ready.id], total: queue_totals[ready.id], file: 'done')
|
|
181
208
|
progress.refresh
|
|
182
209
|
end
|
|
183
210
|
rescue WorkerError => e
|
|
184
|
-
|
|
211
|
+
warn "worker #{ready.id} error: #{e.message}"
|
|
185
212
|
all_passed = false
|
|
186
213
|
active.delete(ready)
|
|
187
214
|
end
|
|
@@ -189,6 +216,9 @@ module Binpacker
|
|
|
189
216
|
|
|
190
217
|
progress.finish
|
|
191
218
|
|
|
219
|
+
total_examples = worker_examples.sum
|
|
220
|
+
passed_examples = worker_passed.sum
|
|
221
|
+
|
|
192
222
|
worker_stats = workers.map.with_index do |w, i|
|
|
193
223
|
tw = w.timings.sum { |t| t[:time] }
|
|
194
224
|
{
|
|
@@ -199,24 +229,62 @@ module Binpacker
|
|
|
199
229
|
}
|
|
200
230
|
end
|
|
201
231
|
progress.summary(worker_stats)
|
|
232
|
+
write_report(worker_stats, all_timings)
|
|
202
233
|
|
|
203
234
|
workers.each(&:cleanup)
|
|
204
235
|
finalize(timing, all_timings, all_passed, total_examples, passed_examples, tests)
|
|
205
236
|
end
|
|
206
237
|
|
|
207
|
-
def
|
|
238
|
+
def write_report(worker_stats, all_timings)
|
|
239
|
+
return unless @report_path
|
|
240
|
+
|
|
241
|
+
Report.new(
|
|
242
|
+
profile: @config.profile,
|
|
243
|
+
algorithm: @config.scheduler['algorithm'],
|
|
244
|
+
predicted_loads: @predicted_loads,
|
|
245
|
+
worker_stats: worker_stats,
|
|
246
|
+
all_timings: all_timings,
|
|
247
|
+
timings: @timings
|
|
248
|
+
).write(@report_path)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Per-run batch-weight floor. Calibrated runs use the fixed
|
|
252
|
+
# seconds-scale MIN_BATCH_WEIGHT. On a cold start weights are KB, so
|
|
253
|
+
# that floor is meaningless; derive one that splits the total
|
|
254
|
+
# predicted weight into ~COLD_START_BATCHES_PER_WORKER batches per
|
|
255
|
+
# worker, falling back to MIN_BATCH_WEIGHT when there is nothing to
|
|
256
|
+
# divide (zero total weight or zero workers).
|
|
257
|
+
def min_batch_weight(timing, timings)
|
|
258
|
+
return MIN_BATCH_WEIGHT if timing.calibrated?
|
|
259
|
+
|
|
260
|
+
total = timings.values.sum(0.0)
|
|
261
|
+
workers = @config.worker_count
|
|
262
|
+
return MIN_BATCH_WEIGHT if total.zero? || workers.zero?
|
|
263
|
+
|
|
264
|
+
total / (workers * COLD_START_BATCHES_PER_WORKER)
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# Drains the next batch off +queue+: enough tests to halve its
|
|
268
|
+
# remaining predicted weight, but never less than +floor+ (see
|
|
269
|
+
# #min_batch_weight) and never fewer than one test.
|
|
270
|
+
def drain_batch(queue, floor)
|
|
208
271
|
return [] if queue.nil? || queue.empty?
|
|
272
|
+
|
|
273
|
+
target = [queue.total_weight(@timings) / 2.0, floor].max
|
|
209
274
|
batch = []
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
break unless test
|
|
275
|
+
weight = 0.0
|
|
276
|
+
while (batch.empty? || weight < target) && (test = queue.pop)
|
|
213
277
|
batch << test
|
|
278
|
+
weight += @timings.fetch(test.key, Timing::DEFAULT_WEIGHT)
|
|
214
279
|
end
|
|
215
280
|
batch
|
|
216
281
|
end
|
|
217
282
|
|
|
218
283
|
def finalize(timing, all_timings, all_passed, total_examples, passed_examples, tests)
|
|
219
|
-
|
|
284
|
+
unless all_timings.empty?
|
|
285
|
+
timing.append_all(all_timings)
|
|
286
|
+
timing.compact!
|
|
287
|
+
end
|
|
220
288
|
empty_filter = minitest_empty_filter?(tests, total_examples)
|
|
221
289
|
all_passed = false if empty_filter
|
|
222
290
|
|
|
@@ -230,7 +298,7 @@ module Binpacker
|
|
|
230
298
|
end
|
|
231
299
|
|
|
232
300
|
def minitest_empty_filter?(tests, total_examples)
|
|
233
|
-
return false unless @config.test_runner ==
|
|
301
|
+
return false unless @config.test_runner == 'minitest'
|
|
234
302
|
return false unless tests.any?
|
|
235
303
|
return false unless total_examples.zero?
|
|
236
304
|
|
|
@@ -240,8 +308,8 @@ module Binpacker
|
|
|
240
308
|
def minitest_include_filter?
|
|
241
309
|
@passthrough.any? do |arg|
|
|
242
310
|
%w[--name --include -n -i].include?(arg) ||
|
|
243
|
-
arg.start_with?(
|
|
244
|
-
(arg.start_with?(
|
|
311
|
+
arg.start_with?('--name=', '--include=') ||
|
|
312
|
+
(arg.start_with?('-n', '-i') && arg.length > 2)
|
|
245
313
|
end
|
|
246
314
|
end
|
|
247
315
|
end
|