binpacker 0.4.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: 98842f3601fe059ff372b62a214ee73e5e96d0b2c01cf5b651063f04e3f00184
4
- data.tar.gz: 94e73ac26474bf1da1942509f35618e8576d230b685ee69fe09579d4b9387898
3
+ metadata.gz: ae35133b41b6a8423c8caa45b91a4153498099d2f04c790aeada3fb1e4e0d52a
4
+ data.tar.gz: 5e537cf525779a2783db551b1bf86f7525ce34558f16ce8c2eab253712454b8e
5
5
  SHA512:
6
- metadata.gz: 6a983e121b41c7a9e62dad700ee5541813b84c2553e7fc479c3dbedf996358669a9508d2fd7f3eb8df21831c583421ee91382a453ac73a60beead9bf03da0675
7
- data.tar.gz: 657ac41276da7094a5ea54d53936a9237b1b3139f53b741c954427b57f2d781dca56df91746972e33e93f49edb7a93a2155d5b92561653d672f87ca73373669a
6
+ metadata.gz: ab3dd9a1c6993908e7e646c697d649745d029f15c6a77981ec8b2dd3735afebf92f6194a97e60e2923acc5500fa475e2f7118673cc6c18e2c49ba0a4aa7a7dc1
7
+ data.tar.gz: ad30734e5dbadb596dd753f90ff1163bbc0a12fa60f8d080baad357f9ed2be3ac8a9667d2ed8a52dab2ced6e3e76d2931a1368613976f0a4e2a5af3fd1c4a8da
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
@@ -24,20 +24,27 @@ module Binpacker
24
24
  # worker that gave the best makespan in the PR #12 simulation.
25
25
  COLD_START_BATCHES_PER_WORKER = 5
26
26
 
27
- def initialize(config, passthrough: [], quiet: false, report_path: nil)
27
+ def initialize(config, passthrough: [], quiet: false, report_path: nil, shard: nil)
28
28
  @config = config
29
29
  @passthrough = passthrough
30
30
  @quiet = quiet
31
31
  @report_path = report_path
32
+ @shard = shard
32
33
  end
33
34
 
34
35
  def run
35
36
  tests = discover
36
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.
37
40
  timings = timing.load_with_fallback(tests)
38
41
  @timings = timings
39
42
 
40
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
+
41
48
  queues = scheduler.partition(
42
49
  tests: tests,
43
50
  worker_count: @config.worker_count,
@@ -244,7 +251,10 @@ module Binpacker
244
251
  predicted_loads: @predicted_loads,
245
252
  worker_stats: worker_stats,
246
253
  all_timings: all_timings,
247
- timings: @timings
254
+ timings: @timings,
255
+ shard: @shard,
256
+ discovered: @discovered_count,
257
+ selected: @selected_count
248
258
  ).write(@report_path)
249
259
  end
250
260
 
@@ -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
  # Builds the machine-readable Run report: predicted versus actual per-Worker
@@ -9,13 +9,17 @@ module Binpacker
9
9
  SCHEMA = 1
10
10
  DRIFT_LIMIT = 10
11
11
 
12
- def initialize(profile:, algorithm:, predicted_loads:, worker_stats:, all_timings:, timings:)
12
+ def initialize(profile:, algorithm:, predicted_loads:, worker_stats:, all_timings:, timings:,
13
+ shard: nil, discovered: nil, selected: nil)
13
14
  @profile = profile
14
15
  @algorithm = algorithm
15
16
  @predicted_loads = predicted_loads
16
17
  @worker_stats = worker_stats
17
18
  @all_timings = all_timings
18
19
  @timings = timings
20
+ @shard = shard
21
+ @discovered = discovered
22
+ @selected = selected
19
23
  end
20
24
 
21
25
  def to_h
@@ -26,6 +30,7 @@ module Binpacker
26
30
  schema: SCHEMA,
27
31
  profile: @profile,
28
32
  algorithm: @algorithm,
33
+ shard: shard_section,
29
34
  worker_count: @worker_stats.size,
30
35
  predicted_makespan: round(predicted.max || 0.0),
31
36
  actual_makespan: round(actual.max || 0.0),
@@ -44,6 +49,29 @@ module Binpacker
44
49
 
45
50
  private
46
51
 
52
+ # The audit trail for a sharded run, and the reason `discovered` is recorded at all.
53
+ #
54
+ # Shards never talk to each other: each computes the same N-way partition and trusts the others to have
55
+ # computed it identically. They do so only while they agree on the timing data the partition is cut
56
+ # from, which in CI means every shard restoring the same timing cache. A shard that restores a
57
+ # different one — a cache miss where its siblings hit — partitions differently, and the failure is
58
+ # silent: tests land in no shard at all and the build stays green.
59
+ #
60
+ # `discovered` is the whole-suite count before slicing, so it agrees across shards that see the same
61
+ # repository. `selected` is this shard's slice. Summing `selected` over a matrix's reports and
62
+ # comparing to the shared `discovered` turns that silent skip into a failure —
63
+ # `binpacker shards-check` does exactly that.
64
+ def shard_section
65
+ return nil unless @shard
66
+
67
+ {
68
+ index: @shard.index,
69
+ total: @shard.total,
70
+ discovered_tests: @discovered,
71
+ selected_tests: @selected
72
+ }
73
+ end
74
+
47
75
  def workers
48
76
  @worker_stats.map.with_index do |s, i|
49
77
  {
@@ -84,13 +112,15 @@ module Binpacker
84
112
  end
85
113
 
86
114
  def normalize_file(file)
87
- file.to_s.sub(%r{\A\./}, "")
115
+ file.to_s.sub(%r{\A\./}, '')
88
116
  end
89
117
 
90
118
  def deviation_pct(loads)
91
119
  return 0.0 if loads.empty?
120
+
92
121
  mean = loads.sum / loads.size
93
122
  return 0.0 unless mean.positive?
123
+
94
124
  max_dev = loads.map { |t| (t - mean).abs }.max
95
125
  round(max_dev / mean * 100)
96
126
  end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Binpacker
4
+ # One slice of the suite, for splitting a run across independent machines.
5
+ #
6
+ # Workers divide a suite across the cores of ONE machine and share its wall clock; a shard divides it
7
+ # across machines that have no wall clock in common. So the two compose rather than compete: a CI matrix
8
+ # of N jobs each passing `--shard k/N` runs `worker_count` workers over its own slice, and the suite's
9
+ # wall time becomes roughly the slowest shard rather than the whole.
10
+ #
11
+ # The slice is cut by the SAME weight-balanced partitioner that assigns work to workers, over the same
12
+ # measured timings — shards are just a coarser bin-packing of the same problem, so an equal-count split
13
+ # would balance no better than round-robin. That also makes the cut deterministic: given one timing file,
14
+ # every shard computes the identical N-way partition and takes only its own bin, so no shard needs to
15
+ # know what the others decided, and every test lands in exactly one shard.
16
+ class Shard
17
+ attr_reader :index, :total
18
+
19
+ # Parses the `k/n` form used by `--shard` and BINPACKER_SHARD. `k` is 1-based, so a matrix can pass its
20
+ # own 1-based job number straight through.
21
+ #
22
+ # @param spec [String, nil]
23
+ # @return [Shard, nil] nil when `spec` is nil or empty, i.e. an unsharded run.
24
+ def self.parse(spec)
25
+ return nil if spec.nil? || spec.to_s.strip.empty?
26
+
27
+ match = %r{\A\s*(\d+)\s*/\s*(\d+)\s*\z}.match(spec.to_s)
28
+ raise ConfigError, "invalid shard #{spec.inspect}: expected the form K/N, e.g. 1/3" unless match
29
+
30
+ new(index: Integer(match[1]), total: Integer(match[2]))
31
+ end
32
+
33
+ def initialize(index:, total:)
34
+ raise ConfigError, "shard count must be at least 1, got #{total}" if total < 1
35
+ raise ConfigError, "shard index must be between 1 and #{total}, got #{index}" unless (1..total).cover?(index)
36
+
37
+ @index = index
38
+ @total = total
39
+ freeze
40
+ end
41
+
42
+ def to_s
43
+ "#{index}/#{total}"
44
+ end
45
+
46
+ # Whole-suite runs still construct no Shard, so this is only ever true for an explicit `--shard 1/1`.
47
+ def whole_suite?
48
+ total == 1
49
+ end
50
+
51
+ # @param tests [Array<Test>] every test discovered, before any slicing
52
+ # @param timings [Hash] predicted weight per test key, as `Timing#load_with_fallback` returns
53
+ # @param scheduler [Scheduler] the same partitioner the run uses for workers
54
+ # @return [Array<Test>] the tests belonging to this shard
55
+ def select(tests:, timings:, scheduler:)
56
+ return tests if whole_suite?
57
+
58
+ bins = scheduler.partition(tests: tests, worker_count: total, timings: timings)
59
+ bins[index - 1].remaining
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Binpacker
6
+ # Fan-in audit for a sharded matrix: did the shards, between them, actually run the whole suite?
7
+ #
8
+ # Shards never coordinate. Each cuts the same N-way partition from the timing data it loaded and takes
9
+ # its own bin, which is correct exactly as long as they all loaded the SAME data. In CI that means every
10
+ # shard job restoring the same timing cache — and the failure mode when one does not is the bad one:
11
+ # it partitions differently, some tests land in no shard, and every job still reports success. Nothing
12
+ # inside a single shard can notice, because a shard cannot tell "not mine" from "does not exist".
13
+ #
14
+ # So the check belongs after the matrix, over the run reports it produced. Point it at every shard's
15
+ # report and it fails unless the reports describe one coherent split of one suite.
16
+ class ShardCheck
17
+ Result = Struct.new(:ok, :problems, :summary, keyword_init: true) do
18
+ def ok? = ok
19
+ end
20
+
21
+ def self.call(paths) = new(paths).call
22
+
23
+ def initialize(paths)
24
+ @paths = Array(paths)
25
+ end
26
+
27
+ def call
28
+ return failure(['no run reports given']) if @paths.empty?
29
+
30
+ reports, unreadable = load_reports
31
+ return failure(unreadable) unless unreadable.empty?
32
+
33
+ shards = reports.filter_map { |path, data| shard_of(path, data) }
34
+ missing = reports.map(&:first) - shards.map { |s| s[:path] }
35
+ return failure(missing.map { |p| "#{p}: no `shard` section — was it run with --shard?" }) unless missing.empty?
36
+
37
+ problems = check(shards)
38
+ problems.empty? ? success(shards) : failure(problems)
39
+ end
40
+
41
+ private
42
+
43
+ def load_reports
44
+ reports = []
45
+ unreadable = []
46
+ @paths.each do |path|
47
+ reports << [path, JSON.parse(File.read(path))]
48
+ rescue Errno::ENOENT
49
+ unreadable << "#{path}: no such file"
50
+ rescue JSON::ParserError => e
51
+ unreadable << "#{path}: not valid JSON (#{e.message})"
52
+ end
53
+ [reports, unreadable]
54
+ end
55
+
56
+ def shard_of(path, data)
57
+ section = data['shard']
58
+ return nil unless section.is_a?(Hash)
59
+
60
+ {
61
+ path: path,
62
+ index: section['index'],
63
+ total: section['total'],
64
+ discovered: section['discovered_tests'],
65
+ selected: section['selected_tests']
66
+ }
67
+ end
68
+
69
+ def check(shards)
70
+ problems = []
71
+ problems.concat(agreement_problems(shards, :total, 'shard count'))
72
+ problems.concat(agreement_problems(shards, :discovered, 'discovered test count'))
73
+ problems.concat(completeness_problems(shards))
74
+ problems.concat(coverage_problems(shards))
75
+ problems
76
+ end
77
+
78
+ # Every report must describe the same matrix. Disagreement here means the reports were not produced by
79
+ # one run, and no coverage conclusion drawn from them would mean anything.
80
+ def agreement_problems(shards, field, label)
81
+ values = shards.map { |s| s[field] }.uniq
82
+ return [] if values.size <= 1
83
+
84
+ ["shards disagree on #{label}: #{values.sort_by(&:to_s).inspect}"]
85
+ end
86
+
87
+ # A matrix that lost a job silently drops that job's slice, which looks exactly like a smaller suite.
88
+ def completeness_problems(shards)
89
+ total = shards.first[:total]
90
+ return [] if total.nil?
91
+
92
+ seen = shards.map { |s| s[:index] }
93
+ duplicates = seen.tally.select { |_, n| n > 1 }.keys.sort
94
+ problems = []
95
+ problems << "shard #{duplicates.join(', ')} reported more than once" unless duplicates.empty?
96
+
97
+ absent = (1..total).to_a - seen
98
+ problems << "no report for shard #{absent.join(', ')} of #{total}" unless absent.empty?
99
+ problems
100
+ end
101
+
102
+ # The check this class exists for: the slices must add up to the suite.
103
+ def coverage_problems(shards)
104
+ discovered = shards.first[:discovered]
105
+ return [] if discovered.nil?
106
+
107
+ selected = shards.sum { |s| s[:selected].to_i }
108
+ return [] if selected == discovered
109
+
110
+ verb = selected < discovered ? 'ran no shard' : 'ran in more than one shard'
111
+ ["shards cover #{selected} of #{discovered} tests — #{(discovered - selected).abs} #{verb}. " \
112
+ 'The shards partitioned different timing data; make every shard load the same timing file.']
113
+ end
114
+
115
+ def success(shards)
116
+ Result.new(
117
+ ok: true,
118
+ problems: [],
119
+ summary: "#{shards.size} shards cover all #{shards.first[:discovered]} tests"
120
+ )
121
+ end
122
+
123
+ def failure(problems)
124
+ Result.new(ok: false, problems: problems, summary: 'shard coverage check failed')
125
+ end
126
+ end
127
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Binpacker
4
- VERSION = "0.4.0"
4
+ VERSION = "0.5.0"
5
5
  end
data/lib/binpacker.rb CHANGED
@@ -1,19 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "binpacker/version"
4
- require_relative "binpacker/config"
5
- require_relative "binpacker/timing"
6
- require_relative "binpacker/test_discovery"
7
- require_relative "binpacker/worker_queue"
8
- require_relative "binpacker/scheduler"
9
- require_relative "binpacker/worker"
10
- require_relative "binpacker/test_runner"
11
- require_relative "binpacker/calibration"
12
- require_relative "binpacker/report"
13
- require_relative "binpacker/skills"
14
- require_relative "binpacker/project_state"
15
- require_relative "binpacker/orchestrator"
16
- require_relative "binpacker/progress"
3
+ require_relative 'binpacker/version'
4
+ require_relative 'binpacker/config'
5
+ require_relative 'binpacker/timing'
6
+ require_relative 'binpacker/test_discovery'
7
+ require_relative 'binpacker/worker_queue'
8
+ require_relative 'binpacker/scheduler'
9
+ require_relative 'binpacker/shard'
10
+ require_relative 'binpacker/shard_check'
11
+ require_relative 'binpacker/worker'
12
+ require_relative 'binpacker/test_runner'
13
+ require_relative 'binpacker/calibration'
14
+ require_relative 'binpacker/report'
15
+ require_relative 'binpacker/skills'
16
+ require_relative 'binpacker/project_state'
17
+ require_relative 'binpacker/orchestrator'
18
+ require_relative 'binpacker/progress'
17
19
 
18
20
  module Binpacker
19
21
  Error = Class.new(StandardError)
@@ -7,10 +7,13 @@ module Binpacker
7
7
  @passthrough: untyped
8
8
  @quiet: untyped
9
9
  @report_path: untyped
10
+ @shard: Binpacker::Shard?
11
+ @discovered_count: Integer
12
+ @selected_count: Integer
10
13
  @timings: Hash[untyped, Float]
11
14
  @predicted_loads: Array[Float]
12
15
 
13
- def initialize: (untyped, ?passthrough: untyped, ?quiet: untyped, ?report_path: untyped) -> void
16
+ def initialize: (untyped, ?passthrough: untyped, ?quiet: untyped, ?report_path: untyped, ?shard: Binpacker::Shard?) -> void
14
17
  def run: () -> { passed: untyped, total: untyped, passed_count: untyped, timings: Array[untyped], empty_filter: untyped }
15
18
 
16
19
  private
@@ -3,7 +3,8 @@ module Binpacker
3
3
  SCHEMA: Integer
4
4
  DRIFT_LIMIT: Integer
5
5
 
6
- def initialize: (profile: untyped, algorithm: untyped, predicted_loads: untyped, worker_stats: untyped, all_timings: untyped, timings: untyped) -> void
6
+ def initialize: (profile: untyped, algorithm: untyped, predicted_loads: untyped, worker_stats: untyped, all_timings: untyped, timings: untyped, ?shard: Binpacker::Shard?, ?discovered: Integer?, ?selected: Integer?) -> void
7
+ def shard_section: () -> Hash[Symbol, untyped]?
7
8
  def to_h: () -> Hash[Symbol, untyped]
8
9
  def write: (untyped) -> untyped
9
10
  end
@@ -1,5 +1,16 @@
1
1
  module Binpacker
2
2
  class Scheduler
3
3
  def self.for: (untyped) -> (Binpacker::LptScheduler | Binpacker::MultifitScheduler)
4
+
5
+ # Declared on the base so a caller holding a `Scheduler` can partition; the base body raises
6
+ # NotImplementedError and every concrete scheduler overrides it.
7
+ def partition: (tests: Array[untyped], worker_count: Integer, timings: Hash[untyped, Float]) -> Array[Binpacker::WorkerQueue]
8
+ end
9
+
10
+ class LptScheduler < Scheduler
11
+ end
12
+
13
+ class MultifitScheduler < Scheduler
14
+ ITERATIONS: Integer
4
15
  end
5
16
  end
@@ -0,0 +1,13 @@
1
+ module Binpacker
2
+ class Shard
3
+ attr_reader index: Integer
4
+ attr_reader total: Integer
5
+
6
+ def self.parse: (String?) -> Binpacker::Shard?
7
+
8
+ def initialize: (index: Integer, total: Integer) -> void
9
+ def to_s: () -> String
10
+ def whole_suite?: () -> bool
11
+ def select: (tests: Array[untyped], timings: Hash[untyped, Float], scheduler: Binpacker::Scheduler) -> Array[untyped]
12
+ end
13
+ end
@@ -0,0 +1,29 @@
1
+ module Binpacker
2
+ class ShardCheck
3
+ class Result
4
+ attr_accessor ok: bool
5
+ attr_accessor problems: Array[String]
6
+ attr_accessor summary: String
7
+
8
+ def ok?: () -> bool
9
+ end
10
+
11
+ @paths: Array[String]
12
+
13
+ def self.call: (Array[String]) -> Binpacker::ShardCheck::Result
14
+
15
+ def initialize: (Array[String]) -> void
16
+ def call: () -> Binpacker::ShardCheck::Result
17
+
18
+ private
19
+
20
+ def load_reports: () -> [Array[[String, untyped]], Array[String]]
21
+ def shard_of: (String, untyped) -> Hash[Symbol, untyped]?
22
+ def check: (Array[Hash[Symbol, untyped]]) -> Array[String]
23
+ def agreement_problems: (Array[Hash[Symbol, untyped]], Symbol, String) -> Array[String]
24
+ def completeness_problems: (Array[Hash[Symbol, untyped]]) -> Array[String]
25
+ def coverage_problems: (Array[Hash[Symbol, untyped]]) -> Array[String]
26
+ def success: (Array[Hash[Symbol, untyped]]) -> Binpacker::ShardCheck::Result
27
+ def failure: (Array[String]) -> Binpacker::ShardCheck::Result
28
+ end
29
+ end
@@ -1,8 +1,17 @@
1
1
  module Binpacker
2
2
  class WorkerQueue
3
+ include Enumerable[untyped]
4
+
5
+ attr_reader worker_id: Integer
6
+
3
7
  def initialize: (untyped, ?untyped) -> void
4
8
  def empty?: () -> (false | true)
5
9
  def pop: () -> untyped
10
+ def peek: () -> untyped
11
+ def size: () -> Integer
12
+ def remaining: () -> Array[untyped]
6
13
  def total_weight: (Hash[untyped, Float]) -> Float
14
+ def push: (untyped) -> untyped
15
+ def each: () { (untyped) -> void } -> void
7
16
  end
8
17
  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.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - megurine
@@ -29,6 +29,8 @@ files:
29
29
  - lib/binpacker/project_state.rb
30
30
  - lib/binpacker/report.rb
31
31
  - lib/binpacker/scheduler.rb
32
+ - lib/binpacker/shard.rb
33
+ - lib/binpacker/shard_check.rb
32
34
  - lib/binpacker/skills.rb
33
35
  - lib/binpacker/test_discovery.rb
34
36
  - lib/binpacker/test_runner.rb
@@ -44,6 +46,8 @@ files:
44
46
  - sig/binpacker/project_state.rbs
45
47
  - sig/binpacker/report.rbs
46
48
  - sig/binpacker/scheduler.rbs
49
+ - sig/binpacker/shard.rbs
50
+ - sig/binpacker/shard_check.rbs
47
51
  - sig/binpacker/skills.rbs
48
52
  - sig/binpacker/test_discovery.rbs
49
53
  - sig/binpacker/test_runner.rbs