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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 991ac5d941b69573b4f6d03c211ff862465a91ee31ddd82f5ce8d8150a2d4f0d
4
- data.tar.gz: 8ad2f245f73a46a4fdd64272049835c36d3b2dc4a0bb5df9cf67f851789ae59a
3
+ metadata.gz: ae35133b41b6a8423c8caa45b91a4153498099d2f04c790aeada3fb1e4e0d52a
4
+ data.tar.gz: 5e537cf525779a2783db551b1bf86f7525ce34558f16ce8c2eab253712454b8e
5
5
  SHA512:
6
- metadata.gz: 4345168c76cd0e072ab8f0775df54fe705e13f873a1f7f8ce764b897f42f91f75c5b4b0dee07329b577200230a7707a90284e73b554a339798575e084ce93f15
7
- data.tar.gz: 40408cf8b88b878033226df711fbf3e57c217293b85b6eb7f94f55289eb7740d02dcc66598c5f594a3cd21821b5186eb38942c7e03747838a6487ccc37e018d1
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
- $stdin.each_line do |line|
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? && ($stdin.eof? || $stdin.closed?)
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 "optparse"
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 "calibrate"
32
+ when 'calibrate'
30
33
  cmd_calibrate
31
- when "run"
34
+ when 'run'
32
35
  cmd_run
33
- when "init"
36
+ when 'shards-check'
37
+ cmd_shards_check
38
+ when 'init'
34
39
  cmd_init
35
- when "skill"
40
+ when 'skill'
36
41
  cmd_skill
37
- when "describe"
42
+ when 'describe'
38
43
  cmd_describe
39
- when "--version", "-v"
44
+ when '--version', '-v'
40
45
  puts "binpacker #{Binpacker::VERSION}"
41
- when "--help", "-h", nil
46
+ when '--help', '-h', nil
42
47
  print_help
43
48
  else
44
- $stderr.puts "unknown command: #{@command}"
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 = "Usage: binpacker <command> [options]"
59
+ opts.banner = 'Usage: binpacker <command> [options]'
55
60
 
56
- opts.on("--profile PROFILE", "Profile name from binpacker.yml") do |v|
61
+ opts.on('--profile PROFILE', 'Profile name from binpacker.yml') do |v|
57
62
  @profile = v
58
63
  end
59
64
 
60
- opts.on("--version", "Show version") do
61
- @command ||= "--version"
65
+ opts.on('--version', 'Show version') do
66
+ @command ||= '--version'
62
67
  end
63
68
 
64
- opts.on("--help", "Show help") do
65
- @command ||= "--help"
69
+ opts.on('--help', 'Show help') do
70
+ @command ||= '--help'
66
71
  end
67
72
 
68
- opts.on("--quiet", "Suppress worker output") do
73
+ opts.on('--quiet', 'Suppress worker output') do
69
74
  @quiet = true
70
75
  end
71
76
 
72
- opts.on("--incremental", "Calibrate only tests without timing data") do
77
+ opts.on('--incremental', 'Calibrate only tests without timing data') do
73
78
  @incremental = true
74
79
  end
75
80
 
76
- opts.on("--report PATH", "(run) Write a JSON run report to PATH") do |v|
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("--list", "(skill) List bundled skills") do
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("--path", "(skill) Print the SKILL.md path for <name>") do
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("--describe", "(skill) Report project state and next skill") do
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.shift
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("binpacker.yml")
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
- $stderr.puts "Detected #{framework} — binpacker supports rspec and minitest only."
116
- $stderr.puts "No binpacker.yml was created."
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 == "minitest" ? "test/**/*_test.rb" : "spec/**/*_spec.rb"
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 "Next steps:"
142
- puts " 1. binpacker calibrate (seed timing data)"
143
- puts " 2. binpacker run (run in parallel)"
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 == "rspec" ? RSpecDiscovery : MinitestDiscovery
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
- $stderr.puts "unknown skill: #{@skill_name}"
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 "binpacker project state:"
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 "No bundled skills found."
223
+ puts 'No bundled skills found.'
212
224
  return
213
225
  end
214
- puts "Bundled skills:"
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
- $stderr.puts "unknown skill: #{name}"
223
- $stderr.puts "Run `binpacker skill` to list bundled skills."
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
- puts "binpacker starting (#{config.worker_count} workers, profile: #{config.profile})"
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 #{config.worker_count} workers."
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 "No tests matched the Minitest filter."
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 == "rspec" ? "example" : "test"
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 || "rspec"
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
- BATCH_SIZE = 10
6
-
7
- def initialize(config, passthrough: [], quiet: false, report_path: nil)
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["algorithm"])
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 |queue, idx|
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["steal_enabled"]
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 "rspec"
73
+ when 'rspec'
47
74
  RSpecDiscovery.new(@config).enumerate
48
- when "minitest"
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: "done")
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
- $stderr.puts "worker #{worker.id} error: #{e.message}"
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 |w, i|
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: "done")
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| w.status == :crashed || w.status == :error }
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(&:size)
171
- next_batch = drain_batch(donor) if donor
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: "done")
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
- $stderr.puts "worker #{ready.id} error: #{e.message}"
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["algorithm"],
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
- def drain_batch(queue)
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
- BATCH_SIZE.times do
231
- test = queue.pop
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
- timing.append_all(all_timings) unless all_timings.empty?
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 == "minitest"
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?("--name=", "--include=") ||
264
- (arg.start_with?("-n", "-i") && arg.length > 2)
321
+ arg.start_with?('--name=', '--include=') ||
322
+ (arg.start_with?('-n', '-i') && arg.length > 2)
265
323
  end
266
324
  end
267
325
  end