binpacker 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 991ac5d941b69573b4f6d03c211ff862465a91ee31ddd82f5ce8d8150a2d4f0d
4
- data.tar.gz: 8ad2f245f73a46a4fdd64272049835c36d3b2dc4a0bb5df9cf67f851789ae59a
3
+ metadata.gz: 98842f3601fe059ff372b62a214ee73e5e96d0b2c01cf5b651063f04e3f00184
4
+ data.tar.gz: 94e73ac26474bf1da1942509f35618e8576d230b685ee69fe09579d4b9387898
5
5
  SHA512:
6
- metadata.gz: 4345168c76cd0e072ab8f0775df54fe705e13f873a1f7f8ce764b897f42f91f75c5b4b0dee07329b577200230a7707a90284e73b554a339798575e084ce93f15
7
- data.tar.gz: 40408cf8b88b878033226df711fbf3e57c217293b85b6eb7f94f55289eb7740d02dcc66598c5f594a3cd21821b5186eb38942c7e03747838a6487ccc37e018d1
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
- $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"
@@ -2,7 +2,27 @@
2
2
 
3
3
  module Binpacker
4
4
  class Orchestrator
5
- BATCH_SIZE = 10
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
6
26
 
7
27
  def initialize(config, passthrough: [], quiet: false, report_path: nil)
8
28
  @config = config
@@ -17,7 +37,7 @@ module Binpacker
17
37
  timings = timing.load_with_fallback(tests)
18
38
  @timings = timings
19
39
 
20
- scheduler = Scheduler.for(@config.scheduler["algorithm"])
40
+ scheduler = Scheduler.for(@config.scheduler['algorithm'])
21
41
  queues = scheduler.partition(
22
42
  tests: tests,
23
43
  worker_count: @config.worker_count,
@@ -28,12 +48,12 @@ module Binpacker
28
48
  @predicted_loads = queues.map { |q| q.total_weight(timings) }
29
49
 
30
50
  runner_class = TestRunner.for(@config.test_runner)
31
- workers = queues.map.with_index do |queue, idx|
51
+ workers = queues.map.with_index do |_queue, idx|
32
52
  Worker.new(idx, runner_class, passthrough: @passthrough, quiet: @quiet).tap(&:start)
33
53
  end
34
54
 
35
- if @config.scheduler["steal_enabled"]
36
- run_dynamic(workers, queues, timing, tests)
55
+ if @config.scheduler['steal_enabled']
56
+ run_dynamic(workers, queues, timing, tests, min_batch_weight(timing, timings))
37
57
  else
38
58
  run_static(workers, queues, timing, tests)
39
59
  end
@@ -43,9 +63,9 @@ module Binpacker
43
63
 
44
64
  def discover
45
65
  case @config.test_runner
46
- when "rspec"
66
+ when 'rspec'
47
67
  RSpecDiscovery.new(@config).enumerate
48
- when "minitest"
68
+ when 'minitest'
49
69
  MinitestDiscovery.new(@config).enumerate
50
70
  else
51
71
  raise ConfigError, "unsupported runner: #{@config.test_runner}"
@@ -58,7 +78,7 @@ module Binpacker
58
78
 
59
79
  workers.zip(queues).each do |worker, queue|
60
80
  worker.send_tests(queue.remaining)
61
- 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 || '')
62
82
  end
63
83
  progress.refresh
64
84
 
@@ -81,10 +101,10 @@ module Binpacker
81
101
  worker_examples[worker.id] = worker.example_count
82
102
  worker_passed[worker.id] = worker.passed_count
83
103
  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")
104
+ progress.update(worker.id, done: queue_totals[worker.id], total: queue_totals[worker.id], file: 'done')
85
105
  progress.refresh
86
106
  rescue WorkerError => e
87
- $stderr.puts "worker #{worker.id} error: #{e.message}"
107
+ warn "worker #{worker.id} error: #{e.message}"
88
108
  all_passed = false
89
109
  ensure
90
110
  worker.cleanup
@@ -92,7 +112,7 @@ module Binpacker
92
112
 
93
113
  progress.finish
94
114
 
95
- worker_stats = workers.map.with_index do |w, i|
115
+ worker_stats = workers.map.with_index do |_w, i|
96
116
  {
97
117
  files: queue_totals[i],
98
118
  total_time: worker_time[i],
@@ -106,11 +126,9 @@ module Binpacker
106
126
  finalize(timing, all_timings, all_passed, total_examples, passed_examples, tests)
107
127
  end
108
128
 
109
- def run_dynamic(workers, queues, timing, tests)
129
+ def run_dynamic(workers, queues, timing, tests, floor)
110
130
  all_timings = []
111
131
  all_passed = true
112
- total_examples = 0
113
- passed_examples = 0
114
132
  active = []
115
133
 
116
134
  queue_totals = queues.map(&:size)
@@ -123,25 +141,23 @@ module Binpacker
123
141
  progress = ProgressDisplay.new(workers.size)
124
142
 
125
143
  workers.zip(queues).each do |worker, queue|
126
- batch = drain_batch(queue)
144
+ batch = drain_batch(queue, floor)
127
145
  if batch.empty?
128
146
  worker.signal_done
129
147
  worker.collect_results
130
148
  all_timings.concat(worker.timings)
131
149
  all_passed &&= worker.success?
132
- total_examples += worker.example_count
133
- passed_examples += worker.passed_count
134
150
  worker_examples[worker.id] = worker.example_count
135
151
  worker_passed[worker.id] = worker.passed_count
136
152
  worker.cleanup
137
153
  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")
154
+ progress.update(worker.id, done: worker_done[worker.id], total: queue_totals[worker.id], file: 'done')
139
155
  else
140
156
  worker.send_tests(batch)
141
157
  worker.batch_done
142
158
  active << worker
143
159
  batch_sizes[worker.id] = batch.size
144
- current_file = batch.first&.file || ""
160
+ current_file = batch.first&.file || ''
145
161
  progress.update(worker.id, done: 0, total: queue_totals[worker.id], file: current_file)
146
162
  end
147
163
  end
@@ -149,33 +165,38 @@ module Binpacker
149
165
  until active.empty?
150
166
  ready = active.find { |w| w.wait_for_batch }
151
167
  unless ready
152
- active.reject! { |w| w.status == :crashed || w.status == :error }
168
+ active.reject! { |w| %i[crashed error].include?(w.status) }
153
169
  sleep 0.1
154
170
  next
155
171
  end
156
172
 
157
173
  begin
158
174
  all_passed &&= ready.success?
159
- total_examples += ready.example_count
160
- passed_examples += ready.passed_count
161
175
 
162
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.
163
180
  worker_examples[ready.id] = ready.example_count
164
181
  worker_passed[ready.id] = ready.passed_count
165
182
 
166
183
  own_queue = queues[ready.id]
167
- next_batch = drain_batch(own_queue)
184
+ next_batch = drain_batch(own_queue, floor)
168
185
 
169
186
  if next_batch.empty?
170
- donor = queues.reject(&:empty?).max_by(&:size)
171
- next_batch = drain_batch(donor) if donor
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
172
193
  end
173
194
 
174
195
  if next_batch.any?
175
196
  ready.send_tests(next_batch)
176
197
  ready.batch_done
177
198
  batch_sizes[ready.id] = next_batch.size
178
- current_file = next_batch.first&.file || ""
199
+ current_file = next_batch.first&.file || ''
179
200
  progress.update(ready.id, done: worker_done[ready.id], total: queue_totals[ready.id], file: current_file)
180
201
  progress.refresh
181
202
  else
@@ -183,11 +204,11 @@ module Binpacker
183
204
  all_timings.concat(ready.timings)
184
205
  active.delete(ready)
185
206
  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")
207
+ progress.update(ready.id, done: queue_totals[ready.id], total: queue_totals[ready.id], file: 'done')
187
208
  progress.refresh
188
209
  end
189
210
  rescue WorkerError => e
190
- $stderr.puts "worker #{ready.id} error: #{e.message}"
211
+ warn "worker #{ready.id} error: #{e.message}"
191
212
  all_passed = false
192
213
  active.delete(ready)
193
214
  end
@@ -195,6 +216,9 @@ module Binpacker
195
216
 
196
217
  progress.finish
197
218
 
219
+ total_examples = worker_examples.sum
220
+ passed_examples = worker_passed.sum
221
+
198
222
  worker_stats = workers.map.with_index do |w, i|
199
223
  tw = w.timings.sum { |t| t[:time] }
200
224
  {
@@ -216,7 +240,7 @@ module Binpacker
216
240
 
217
241
  Report.new(
218
242
  profile: @config.profile,
219
- algorithm: @config.scheduler["algorithm"],
243
+ algorithm: @config.scheduler['algorithm'],
220
244
  predicted_loads: @predicted_loads,
221
245
  worker_stats: worker_stats,
222
246
  all_timings: all_timings,
@@ -224,19 +248,43 @@ module Binpacker
224
248
  ).write(@report_path)
225
249
  end
226
250
 
227
- def drain_batch(queue)
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)
228
271
  return [] if queue.nil? || queue.empty?
272
+
273
+ target = [queue.total_weight(@timings) / 2.0, floor].max
229
274
  batch = []
230
- BATCH_SIZE.times do
231
- test = queue.pop
232
- break unless test
275
+ weight = 0.0
276
+ while (batch.empty? || weight < target) && (test = queue.pop)
233
277
  batch << test
278
+ weight += @timings.fetch(test.key, Timing::DEFAULT_WEIGHT)
234
279
  end
235
280
  batch
236
281
  end
237
282
 
238
283
  def finalize(timing, all_timings, all_passed, total_examples, passed_examples, tests)
239
- timing.append_all(all_timings) unless all_timings.empty?
284
+ unless all_timings.empty?
285
+ timing.append_all(all_timings)
286
+ timing.compact!
287
+ end
240
288
  empty_filter = minitest_empty_filter?(tests, total_examples)
241
289
  all_passed = false if empty_filter
242
290
 
@@ -250,7 +298,7 @@ module Binpacker
250
298
  end
251
299
 
252
300
  def minitest_empty_filter?(tests, total_examples)
253
- return false unless @config.test_runner == "minitest"
301
+ return false unless @config.test_runner == 'minitest'
254
302
  return false unless tests.any?
255
303
  return false unless total_examples.zero?
256
304
 
@@ -260,8 +308,8 @@ module Binpacker
260
308
  def minitest_include_filter?
261
309
  @passthrough.any? do |arg|
262
310
  %w[--name --include -n -i].include?(arg) ||
263
- arg.start_with?("--name=", "--include=") ||
264
- (arg.start_with?("-n", "-i") && arg.length > 2)
311
+ arg.start_with?('--name=', '--include=') ||
312
+ (arg.start_with?('-n', '-i') && arg.length > 2)
265
313
  end
266
314
  end
267
315
  end
@@ -7,7 +7,7 @@ module Binpacker
7
7
  def initialize(worker_count, tty: $stdout.tty?)
8
8
  @worker_count = worker_count
9
9
  @tty = tty
10
- @workers = Array.new(worker_count) { { done: 0, total: 0, file: "", elapsed: 0.0 } }
10
+ @workers = Array.new(worker_count) { { done: 0, total: 0, file: '', elapsed: 0.0 } }
11
11
  @start = Time.now
12
12
  @last_ci_output = Time.at(Time.now.to_f - CI_INTERVAL)
13
13
  @lines_written = 0
@@ -32,8 +32,9 @@ module Binpacker
32
32
  end
33
33
  end
34
34
 
35
- def finish(worker_stats = [])
35
+ def finish(_worker_stats = [])
36
36
  return unless @tty
37
+
37
38
  redraw
38
39
  $stdout.puts
39
40
  end
@@ -42,9 +43,10 @@ module Binpacker
42
43
  active = worker_stats.reject { |s| s[:files] == 0 && s[:examples] == 0 }
43
44
  return if active.empty?
44
45
 
45
- active.each_with_index do |s, i|
46
+ worker_stats.each_with_index do |s, wid|
47
+ next if s[:files] == 0 && s[:examples] == 0
48
+
46
49
  t = format_time(s[:total_time])
47
- wid = worker_stats.index(s)
48
50
  $stdout.puts " Worker #{wid}: #{s[:files]} tests, #{t} | #{s[:examples]} examples, #{s[:passed]} passed"
49
51
  end
50
52
  total_tests = active.sum { |s| s[:files] }
@@ -55,7 +57,7 @@ module Binpacker
55
57
  max_dev = times.map { |t| (t - mean).abs }.max
56
58
  dev_pct = mean > 0 ? (max_dev / mean * 100).round(1) : 0
57
59
 
58
- $stdout.puts " ──"
60
+ $stdout.puts ' ──'
59
61
  $stdout.puts " Total: #{total_tests} tests, #{format_time(total_time)} | #{total_examples} examples"
60
62
  $stdout.puts " Balance: max deviation #{format_time(max_dev)} (#{dev_pct}%)"
61
63
  end
@@ -67,7 +69,7 @@ module Binpacker
67
69
  clear_lines
68
70
  @workers.each_with_index do |w, i|
69
71
  bar = build_bar(w[:done], w[:total])
70
- status = w[:total] > 0 && w[:done] >= w[:total] ? "done" : w[:file][-50..] || ""
72
+ status = w[:total] > 0 && w[:done] >= w[:total] ? 'done' : w[:file][-50..] || ''
71
73
  $stdout.puts format_line(i, bar, w[:done], w[:total], status, w[:elapsed])
72
74
  end
73
75
  @lines_written = @worker_count
@@ -76,13 +78,15 @@ module Binpacker
76
78
 
77
79
  def clear_lines
78
80
  return if @lines_written == 0
81
+
79
82
  @lines_written.times do
80
83
  $stdout.print "\033[A\033[K"
81
84
  end
82
85
  end
83
86
 
84
87
  def build_bar(done, total)
85
- return "[----------]" if total == 0
88
+ return '[----------]' if total == 0
89
+
86
90
  width = 10
87
91
  filled = (done.to_f / total * width).round
88
92
  "[#{'█' * filled}#{'░' * (width - filled)}]"
@@ -96,10 +100,11 @@ module Binpacker
96
100
  def periodic_output
97
101
  now = Time.now
98
102
  return if now - @last_ci_output < CI_INTERVAL
103
+
99
104
  @last_ci_output = now
100
105
 
101
106
  parts = @workers.map.with_index do |w, i|
102
- ratio = w[:total] > 0 ? "#{w[:done]}/#{w[:total]}" : "0/?"
107
+ ratio = w[:total] > 0 ? "#{w[:done]}/#{w[:total]}" : '0/?'
103
108
  "W#{i}: #{ratio}"
104
109
  end
105
110
  elapsed = (now - @start).round(1)
@@ -108,7 +113,8 @@ module Binpacker
108
113
  end
109
114
 
110
115
  def format_time(seconds)
111
- return " 0.0s" if seconds < 0.001
116
+ return ' 0.0s' if seconds < 0.001
117
+
112
118
  m = (seconds / 60).floor
113
119
  s = (seconds % 60).round(1)
114
120
  m > 0 ? "#{m}m#{s.to_s.rjust(4, '0')}s" : "#{s.to_s.rjust(5)}s"
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "json"
3
+ require 'json'
4
4
 
5
5
  module Binpacker
6
6
  class Timing
@@ -8,40 +8,69 @@ module Binpacker
8
8
 
9
9
  DEFAULT_WEIGHT = 1.0
10
10
 
11
+ # Samples retained per test by #compact! and consulted by the
12
+ # median in #load_per_file. Three samples make a single anomalous
13
+ # run (GC pause, noisy CI neighbour) unable to move the weight.
14
+ MAX_SAMPLES_PER_TEST = 3
15
+
11
16
  def initialize(path)
12
17
  @path = Pathname(path)
13
18
  end
14
19
 
20
+ # True once any timing samples exist, i.e. the project has been
21
+ # calibrated at least once. Callers use this to tell a measured
22
+ # run (weights in seconds) from a pure cold start (fallbacks only).
23
+ def calibrated?
24
+ !samples_by_test.empty?
25
+ end
26
+
15
27
  def load_with_fallback(tests)
16
28
  per_file = load_per_file
29
+ coefficient = seconds_per_kb(per_file)
17
30
  tests.each_with_object({}) do |test, hash|
18
31
  key = normalize_path(test.file)
19
- hash[test.key] = per_file.fetch(key) { filesize_weight(test.file) }
32
+ hash[test.key] = per_file.fetch(key) { fallback_weight(test.file, coefficient) }
20
33
  end
21
34
  end
22
35
 
23
36
  def load_raw
24
- return {} unless @path.exist?
25
-
26
- @path.each_line(encoding: "UTF-8")
27
- .map { |line| parse_line(line) }
28
- .compact
29
- .group_by { |e| [normalize_path(e.file), e.name] }
30
- .transform_values { |entries| entries.last.time }
37
+ @load_raw ||= samples_by_test.transform_values(&:last)
31
38
  end
32
39
 
40
+ # Predicted weight per file: the median of each test's recent
41
+ # samples, summed per file. The append-only history must NOT be
42
+ # summed wholesale — a file present in N historical runs would
43
+ # weigh ~N times its true cost, so long-lived files dominate and
44
+ # newly added ones are starved, skewing the partition.
33
45
  def load_per_file
34
- return {} unless @path.exist?
46
+ samples_by_test.each_with_object({}) do |((file, _name), times), per_file|
47
+ weight = median(times.last(MAX_SAMPLES_PER_TEST))
48
+ per_file[file] = per_file.fetch(file, 0.0) + weight
49
+ end
50
+ end
35
51
 
36
- @path.each_line(encoding: "UTF-8")
37
- .map { |line| parse_line(line) }
38
- .compact
39
- .group_by { |e| normalize_path(e.file) }
40
- .transform_values { |entries| entries.sum(&:time) }
52
+ # Rewrites the timing file keeping only the most recent
53
+ # MAX_SAMPLES_PER_TEST samples per test, so the append-only
54
+ # history (and any CI cache built from it) stays bounded instead
55
+ # of growing by one run per invocation.
56
+ def compact!
57
+ samples = samples_by_test
58
+ return if samples.empty?
59
+
60
+ tmp = Pathname("#{@path}.tmp")
61
+ tmp.open('w', encoding: 'UTF-8') do |io|
62
+ samples.each do |(file, name), times|
63
+ times.last(MAX_SAMPLES_PER_TEST).each do |time|
64
+ io.puts JSON.generate({ file: file, name: name, time: time })
65
+ end
66
+ end
67
+ end
68
+ File.rename(tmp.to_s, @path.to_s)
69
+ invalidate
41
70
  end
42
71
 
43
72
  def normalize_path(path)
44
- Pathname(path).cleanpath.to_s.sub(/\A\.\//, "")
73
+ Pathname(path).cleanpath.to_s.sub(%r{\A\./}, '')
45
74
  end
46
75
 
47
76
  # True when a measured Weight already exists for this Test.
@@ -49,34 +78,103 @@ module Binpacker
49
78
  load_raw.key?([normalize_path(file), name])
50
79
  end
51
80
 
52
- def weight_for(file:, name:)
53
- measured = load_raw
54
- measured.fetch([file, name]) { filesize_weight(file) }
55
- end
56
-
57
81
  def append(file:, name:, time:)
58
82
  @path.dirname.mkpath unless @path.dirname.directory?
59
- @path.open("a", encoding: "UTF-8") { |io| io.puts JSON.generate({ file: file, name: name, time: time }) }
83
+ @path.open('a', encoding: 'UTF-8') { |io| io.puts JSON.generate({ file: file, name: name, time: time }) }
84
+ invalidate
60
85
  end
61
86
 
62
87
  def append_all(entries)
63
88
  return if entries.empty?
89
+
64
90
  @path.dirname.mkpath unless @path.dirname.directory?
65
- @path.open("a", encoding: "UTF-8") do |io|
91
+ @path.open('a', encoding: 'UTF-8') do |io|
66
92
  entries.each { |e| io.puts JSON.generate({ file: e[:file], name: e[:name], time: e[:time] }) }
67
93
  end
94
+ invalidate
68
95
  end
69
96
 
70
97
  private
71
98
 
99
+ # [normalized file, name] => [sample, ...] in append (= run) order.
100
+ def samples_by_test
101
+ @samples_by_test ||= begin
102
+ samples = Hash.new { |h, k| h[k] = [] }
103
+ if @path.exist?
104
+ @path.each_line(encoding: 'UTF-8') do |line|
105
+ entry = parse_line(line)
106
+ samples[[normalize_path(entry.file), entry.name]] << entry.time if entry
107
+ end
108
+ end
109
+ samples.default_proc = nil
110
+ samples
111
+ end
112
+ end
113
+
114
+ def invalidate
115
+ @samples_by_test = nil
116
+ @load_raw = nil
117
+ end
118
+
119
+ def median(values)
120
+ sorted = values.sort
121
+ mid = sorted.size / 2
122
+ sorted.size.odd? ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2.0
123
+ end
124
+
72
125
  def filesize_weight(file)
73
126
  path = Pathname(file)
74
127
  path.exist? ? [path.size / 1024.0, DEFAULT_WEIGHT].max : DEFAULT_WEIGHT
75
128
  end
76
129
 
130
+ # Seconds-per-KB coefficient used to scale filesize fallbacks onto
131
+ # the same axis as measured Weights. Measured Weights are seconds;
132
+ # the raw filesize fallback is KB, so mixing them (batch floors,
133
+ # donor selection) compares apples to oranges — 30s of predicted
134
+ # work reads as "30 KB of files". We estimate a conversion from
135
+ # the files we DO have timings for: for each measured file that
136
+ # still exists on disk with size > 0, take measured_seconds /
137
+ # size_kb, and use the median of those ratios (robust to a few
138
+ # outlier files that are unusually fast or slow for their size).
139
+ # nil when nothing can be estimated (no measurements, or none of
140
+ # the measured files exist on disk) — callers then keep raw KB.
141
+ def seconds_per_kb(per_file)
142
+ ratios = per_file.filter_map do |file, seconds|
143
+ kb = size_kb(file)
144
+ next if kb.nil? || kb <= 0
145
+
146
+ seconds / kb
147
+ end
148
+ ratios.empty? ? nil : median(ratios)
149
+ end
150
+
151
+ # Predicted Weight for an unmeasured Test. Without a coefficient we
152
+ # keep the legacy raw-KB behavior (floored at 1.0). With one we
153
+ # scale filesize into seconds: an existing file becomes
154
+ # size_kb * coefficient (floored at 0.01 to avoid degenerate zero
155
+ # weights, but NOT at 1.0 — sub-second predictions are meaningful
156
+ # now that the unit is seconds); a missing/unreadable file falls
157
+ # back to DEFAULT_WEIGHT as a plausible seconds-scale default.
158
+ def fallback_weight(file, coefficient)
159
+ return filesize_weight(file) if coefficient.nil?
160
+
161
+ kb = size_kb(file)
162
+ return DEFAULT_WEIGHT if kb.nil?
163
+
164
+ [kb * coefficient, 0.01].max
165
+ end
166
+
167
+ # File size in KB, or nil when the file does not exist / is unreadable.
168
+ def size_kb(file)
169
+ path = Pathname(file)
170
+ path.exist? ? path.size / 1024.0 : nil
171
+ rescue SystemCallError
172
+ nil
173
+ end
174
+
77
175
  def parse_line(line)
78
176
  data = JSON.parse(line.strip)
79
- Entry.new(file: data["file"], name: data["name"], time: data["time"])
177
+ Entry.new(file: data['file'], name: data['name'], time: data['time'])
80
178
  rescue JSON::ParserError
81
179
  nil
82
180
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Binpacker
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
@@ -37,7 +37,7 @@ module Binpacker
37
37
  end
38
38
 
39
39
  def total_weight(timings)
40
- remaining.sum { |t| timings.fetch(t.key, Timing::DEFAULT_WEIGHT) }
40
+ remaining.sum(0.0) { |t| timings.fetch(t.key, Timing::DEFAULT_WEIGHT) }
41
41
  end
42
42
 
43
43
  def push(test)
@@ -1,6 +1,28 @@
1
1
  module Binpacker
2
2
  class Orchestrator
3
+ MIN_BATCH_WEIGHT: Float
4
+ COLD_START_BATCHES_PER_WORKER: Integer
5
+
6
+ @config: untyped
7
+ @passthrough: untyped
8
+ @quiet: untyped
9
+ @report_path: untyped
10
+ @timings: Hash[untyped, Float]
11
+ @predicted_loads: Array[Float]
12
+
3
13
  def initialize: (untyped, ?passthrough: untyped, ?quiet: untyped, ?report_path: untyped) -> void
4
14
  def run: () -> { passed: untyped, total: untyped, passed_count: untyped, timings: Array[untyped], empty_filter: untyped }
15
+
16
+ private
17
+
18
+ def discover: () -> untyped
19
+ def run_static: (untyped, untyped, Binpacker::Timing, untyped) -> untyped
20
+ def run_dynamic: (untyped, untyped, Binpacker::Timing, untyped, Float) -> untyped
21
+ def write_report: (untyped, Array[untyped]) -> void
22
+ def min_batch_weight: (Binpacker::Timing, Hash[untyped, Float]) -> Float
23
+ def drain_batch: (Binpacker::WorkerQueue?, Float) -> Array[untyped]
24
+ def finalize: (Binpacker::Timing, Array[untyped], untyped, untyped, untyped, untyped) -> { passed: untyped, total: untyped, passed_count: untyped, timings: Array[untyped], empty_filter: untyped }
25
+ def minitest_empty_filter?: (untyped, untyped) -> bool
26
+ def minitest_include_filter?: () -> bool
5
27
  end
6
28
  end
@@ -1,10 +1,16 @@
1
1
  module Binpacker
2
2
  class Timing
3
3
  def initialize: (untyped) -> void
4
+ DEFAULT_WEIGHT: Float
5
+ MAX_SAMPLES_PER_TEST: Integer
6
+ def calibrated?: () -> bool
7
+ def load_with_fallback: (untyped) -> Hash[untyped, Float]
4
8
  def load_raw: () -> (Hash[[String, untyped], untyped] | {})
5
9
  def load_per_file: () -> (Hash[String, untyped] | {})
6
10
  def normalize_path: (untyped) -> String
7
11
  def measured?: (file: untyped, name: untyped) -> bool
12
+ def compact!: () -> nil
13
+ def append_all: (untyped) -> nil
8
14
  class Entry
9
15
  end
10
16
  end
@@ -2,5 +2,7 @@ module Binpacker
2
2
  class WorkerQueue
3
3
  def initialize: (untyped, ?untyped) -> void
4
4
  def empty?: () -> (false | true)
5
+ def pop: () -> untyped
6
+ def total_weight: (Hash[untyped, Float]) -> Float
5
7
  end
6
8
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: binpacker
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - megurine