Skip to content

Commit ece3685

Browse files
committed
test-bot: speed up parallel dependent setup
1 parent d7c2e40 commit ece3685

File tree

1 file changed

+270
-25
lines changed

1 file changed

+270
-25
lines changed

Library/Homebrew/test_bot/formulae_dependents.rb

Lines changed: 270 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,22 @@
11
# typed: strict
22
# frozen_string_literal: true
33

4+
require "json"
5+
46
module Homebrew
57
module TestBot
68
class FormulaeDependents < TestFormulae
9+
DependentFootprint = T.type_alias { T::Set[String] }
10+
class ParallelDependentTest < T::Struct
11+
const :pid, Integer
12+
const :dependent_name, String
13+
const :lock_set, DependentFootprint
14+
const :result_reader, IO
15+
const :ignore_failures, T::Boolean
16+
end
17+
18+
MAX_PARALLEL_DEPENDENT_TESTS = 2
19+
720
sig { params(testing_formulae: T::Array[String]).returns(T::Array[String]) }
821
attr_writer :testing_formulae
922

@@ -24,6 +37,7 @@ def initialize(tap:, git:, dry_run:, fail_fast:, verbose:)
2437
@testing_formulae_with_tested_dependents = T.let([], T::Array[String])
2538
@tested_dependents_list = T.let(nil, T.nilable(Pathname))
2639
@dependent_testing_formulae = T.let([], T::Array[String])
40+
@prepared_dependency_formulae = T.let(Set.new, T::Set[String])
2741
end
2842

2943
sig { params(args: Homebrew::Cmd::TestBotCmd::Args).void }
@@ -142,8 +156,12 @@ def dependent_formulae!(formula_name, args:)
142156
install_dependent(dependent, testable_dependents, args:) if bottled?(dependent)
143157
end
144158

145-
bottled_dependents.each do |dependent|
146-
install_dependent(dependent, testable_dependents, args:)
159+
if bottled_dependents.one?
160+
bottled_dependents.each do |dependent|
161+
install_dependent(dependent, testable_dependents, args:)
162+
end
163+
else
164+
pipeline_bottled_dependents!(bottled_dependents, testable_dependents, args:)
147165
end
148166
end
149167

@@ -256,15 +274,185 @@ def dependents_for_formula(formula, formula_name, args:)
256274
[source_dependents, bottled_dependents, testable_dependents]
257275
end
258276

277+
sig {
278+
params(bottled_dependents: T::Array[Formula],
279+
testable_dependents: T::Array[Formula], args: Homebrew::Cmd::TestBotCmd::Args).void
280+
}
281+
def pipeline_bottled_dependents!(bottled_dependents, testable_dependents, args:)
282+
info_header "Running bottled dependents:"
283+
puts bottled_dependents
284+
285+
dependent_dependency_sets = T.let(
286+
bottled_dependents.to_h { |dependent| [dependent, dependent_dependencies(dependent, testable_dependents)] },
287+
T::Hash[Formula, T::Array[Dependency]],
288+
)
289+
lock_sets = T.let(
290+
bottled_dependents.to_h do |dependent|
291+
[dependent, dependent_lock_set(dependent, dependent_dependency_sets.fetch(dependent))]
292+
end,
293+
T::Hash[Formula, DependentFootprint],
294+
)
295+
active_tests = T.let({}, T::Hash[Integer, ParallelDependentTest])
296+
pending_dependents = T.let(
297+
bottled_dependents.sort_by do |dependent|
298+
[testable_dependents.include?(dependent) ? 0 : 1,
299+
dependent_mutation_set(dependent, dependent_dependency_sets.fetch(dependent)).length]
300+
end,
301+
T::Array[Formula],
302+
)
303+
304+
until pending_dependents.blank?
305+
dependent = pending_dependents.find do |candidate|
306+
mutation_set = dependent_mutation_set(candidate, dependent_dependency_sets.fetch(candidate))
307+
active_tests.values.none? do |worker|
308+
worker.lock_set.intersect?(mutation_set)
309+
end
310+
end
311+
312+
unless dependent
313+
wait_for_parallel_dependent_test(active_tests)
314+
next
315+
end
316+
317+
pending_dependents.delete(dependent)
318+
319+
worker = install_dependent(
320+
dependent,
321+
testable_dependents,
322+
dependency_closure: dependent_dependency_sets.fetch(dependent),
323+
lock_set: lock_sets.fetch(dependent),
324+
parallel_test: true,
325+
args:,
326+
)
327+
next if worker.nil?
328+
329+
puts "Launching parallel test for #{dependent.full_name}"
330+
active_tests[worker.pid] = worker
331+
wait_for_parallel_dependent_test(active_tests) while active_tests.length >= MAX_PARALLEL_DEPENDENT_TESTS
332+
end
333+
334+
wait_for_parallel_dependent_test(active_tests) until active_tests.blank?
335+
end
336+
337+
sig { params(active_tests: T::Hash[Integer, ParallelDependentTest]).void }
338+
def wait_for_parallel_dependent_test(active_tests)
339+
pid, = Process.wait2
340+
worker = active_tests.delete(pid)
341+
return if worker.nil?
342+
343+
result_reader = worker.result_reader
344+
result = T.let(JSON.parse(result_reader.read), T::Hash[String, T.untyped])
345+
result_reader.close
346+
347+
status = T.cast(result.fetch("status"), String)
348+
step = Step.new(
349+
["brew", "test", "--retry", "--verbose"],
350+
named_args: worker.dependent_name,
351+
env: {},
352+
verbose: false,
353+
ignore_failures: worker.ignore_failures,
354+
repository:,
355+
)
356+
step.instance_variable_set(:@status, status.to_sym)
357+
step.instance_variable_set(:@start_time, Time.at(T.cast(result.fetch("start_time"), Float)))
358+
step.instance_variable_set(:@end_time, Time.at(T.cast(result.fetch("end_time"), Float)))
359+
steps << step
360+
361+
test "brew", "uninstall", "--force", "--ignore-dependencies", worker.dependent_name
362+
363+
if status == "passed"
364+
@tested_dependents_list&.write(worker.dependent_name, mode: "a")
365+
@tested_dependents_list&.write("\n", mode: "a")
366+
end
367+
368+
exit 1 if @fail_fast && status == "failed"
369+
end
370+
371+
sig {
372+
params(
373+
dependent: Formula,
374+
env: T::Hash[String, String],
375+
ignore_failures: T::Boolean,
376+
lock_set: DependentFootprint,
377+
).returns(ParallelDependentTest)
378+
}
379+
def launch_parallel_dependent_test(dependent, env:, ignore_failures:, lock_set:)
380+
result_reader, result_writer = IO.pipe
381+
pid = fork do
382+
result_reader.close
383+
384+
# The parent is responsible for stopping the queue; the child must always report back.
385+
original_fail_fast = @fail_fast
386+
@fail_fast = false
387+
test_header(:FormulaeDependents, method: "parallel_test(#{dependent.full_name})")
388+
test "brew", "test", "--retry", "--verbose",
389+
named_args: dependent.full_name,
390+
env:,
391+
ignore_failures: ignore_failures
392+
step = steps.fetch(-1)
393+
result_writer.write(JSON.generate(
394+
status: step.status.to_s,
395+
start_time: T.must(step.start_time).to_f,
396+
end_time: T.must(step.end_time).to_f,
397+
))
398+
# The child must always report back to the parent, even on unexpected exceptions.
399+
rescue Exception => e # rubocop:disable Lint/RescueException
400+
$stderr.puts e.full_message(highlight: false, order: :top)
401+
result_writer.write(JSON.generate(status: "failed", start_time: Time.now.to_f, end_time: Time.now.to_f))
402+
ensure
403+
@fail_fast = T.must(original_fail_fast)
404+
result_writer.close unless result_writer.closed?
405+
exit!
406+
end
407+
408+
result_writer.close
409+
410+
ParallelDependentTest.new(
411+
pid:,
412+
dependent_name: dependent.full_name,
413+
lock_set:,
414+
result_reader:,
415+
ignore_failures:,
416+
)
417+
end
418+
419+
sig { params(formula: Formula, dependencies: T::Array[Dependency]).returns(DependentFootprint) }
420+
def dependent_lock_set(formula, dependencies)
421+
dependency_formulae = dependencies.map(&:to_formula)
422+
recursive_formulae = [formula, *dependency_formulae].flat_map do |dep_formula|
423+
[dep_formula, *recursive_dependency_formulae(dep_formula)]
424+
end
425+
426+
Set.new(recursive_formulae.uniq(&:full_name).map(&:full_name))
427+
end
428+
429+
sig { params(formula: Formula, dependencies: T::Array[Dependency]).returns(DependentFootprint) }
430+
def dependent_mutation_set(formula, dependencies)
431+
dependency_formulae = dependencies.filter_map do |dependency|
432+
dependency_formula = dependency.to_formula
433+
next if @prepared_dependency_formulae.include?(dependency_formula.name)
434+
next if dependency.satisfied? && (dependency_formula.keg_only? || dependency_formula.linked?)
435+
436+
dependency_formula.full_name
437+
end
438+
439+
Set.new([formula.full_name, *dependency_formulae])
440+
end
441+
259442
sig {
260443
params(
261444
dependent: Formula,
262445
testable_dependents: T::Array[Formula],
263446
args: Homebrew::Cmd::TestBotCmd::Args,
264447
build_from_source: T::Boolean,
265-
).void
448+
dependency_closure: T.nilable(T::Array[Dependency]),
449+
lock_set: T.nilable(DependentFootprint),
450+
parallel_test: T::Boolean,
451+
).returns(T.nilable(ParallelDependentTest))
266452
}
267-
def install_dependent(dependent, testable_dependents, args:, build_from_source: false)
453+
def install_dependent(dependent, testable_dependents, args:, build_from_source: false, dependency_closure: nil,
454+
lock_set: nil,
455+
parallel_test: false)
268456
if @skip_candidates&.include?(dependent.full_name) &&
269457
artifact_cache_valid?(dependent, formulae_dependents: true)
270458
@tested_dependents_list&.write(dependent.full_name, mode: "a")
@@ -290,23 +478,13 @@ def install_dependent(dependent, testable_dependents, args:, build_from_source:
290478
bottled_on_current_version = bottled?(dependent, no_older_versions: true)
291479
dependent_was_previously_installed = dependent.latest_version_installed?
292480

293-
dependent_dependencies = Dependency.expand(
294-
dependent,
295-
cache_key: "test-bot-dependent-dependencies-#{dependent.full_name}",
296-
) do |dep_dependent, dependency|
297-
next if !dependency.build? && !dependency.test? && !dependency.optional?
298-
next if dependency.test? &&
299-
dep_dependent == dependent &&
300-
!dependency.optional? &&
301-
testable_dependents.include?(dependent)
302-
303-
Dependency.prune
304-
end
481+
dependency_closure ||= dependent_dependencies(dependent, testable_dependents)
482+
missing_dependencies = missing_dependency_names(dependency_closure)
305483

306484
unless dependent_was_previously_installed
307485
build_args = []
308486

309-
fetch_formulae = dependent_dependencies.reject(&:satisfied?).map(&:name)
487+
fetch_formulae = missing_dependencies.dup
310488

311489
if build_from_source
312490
required_dependent_reqs = dependent.requirements.reject(&:optional?)
@@ -329,10 +507,11 @@ def install_dependent(dependent, testable_dependents, args:, build_from_source:
329507

330508
unlink_conflicts dependent
331509

332-
test "brew", "install", *build_args, "--only-dependencies",
333-
named_args: dependent.full_name,
334-
ignore_failures: !bottled_on_current_version,
335-
env: { "HOMEBREW_DEVELOPER" => nil }
510+
install_missing_dependency_formulae(
511+
missing_dependencies,
512+
build_args:,
513+
ignore_failures: !bottled_on_current_version,
514+
)
336515

337516
env = {}
338517
env["HOMEBREW_GIT_PATH"] = nil if build_from_source && required_dependent_deps.any? do |d|
@@ -352,7 +531,14 @@ def install_dependent(dependent, testable_dependents, args:, build_from_source:
352531
unlink_conflicts dependent
353532
test "brew", "link", dependent.full_name
354533
end
355-
test "brew", "install", "--only-dependencies", dependent.full_name
534+
535+
# Install the exact missing closure once; for testable dependents this
536+
# already includes direct test deps and their required recursive deps.
537+
install_missing_dependency_formulae(
538+
missing_dependency_names(dependency_closure),
539+
ignore_failures: !bottled_on_current_version,
540+
)
541+
356542
test "brew", "linkage", "--test",
357543
named_args: dependent.full_name,
358544
ignore_failures: !args.test_default_formula? && !bottled_on_current_version
@@ -367,9 +553,7 @@ def install_dependent(dependent, testable_dependents, args:, build_from_source:
367553
end
368554

369555
if testable_dependents.include? dependent
370-
test "brew", "install", "--only-dependencies", "--include-test", dependent.full_name
371-
372-
dependent_dependencies.each do |dependency|
556+
dependency_closure.each do |dependency|
373557
dependency_f = dependency.to_formula
374558
next if dependency_f.keg_only?
375559
next if dependency_f.linked?
@@ -382,6 +566,15 @@ def install_dependent(dependent, testable_dependents, args:, build_from_source:
382566
env["HOMEBREW_GIT_PATH"] = nil if required_dependent_deps.any? do |d|
383567
d.name == "git" && (!d.build? || d.test?)
384568
end
569+
if parallel_test
570+
return launch_parallel_dependent_test(
571+
dependent,
572+
env:,
573+
ignore_failures: !args.test_default_formula? && !bottled_on_current_version,
574+
lock_set: T.must(lock_set),
575+
)
576+
end
577+
385578
test "brew", "test", "--retry", "--verbose",
386579
named_args: dependent.full_name,
387580
env:,
@@ -426,6 +619,58 @@ def install_dependent(dependent, testable_dependents, args:, build_from_source:
426619
title: "#{dependent} should be bottled for #{os_string}!",
427620
)
428621
end
622+
623+
nil
624+
end
625+
626+
sig { params(dependencies: T::Array[Dependency]).returns(T::Array[String]) }
627+
def missing_dependency_names(dependencies)
628+
dependencies.reject(&:satisfied?).map(&:name).reject do |formula_name|
629+
@prepared_dependency_formulae.include?(formula_name)
630+
end.uniq.sort
631+
end
632+
633+
sig {
634+
params(
635+
formula_names: T::Array[String],
636+
ignore_failures: T::Boolean,
637+
build_args: T::Array[String],
638+
).void
639+
}
640+
def install_missing_dependency_formulae(formula_names, ignore_failures:, build_args: [])
641+
return if formula_names.blank?
642+
643+
test "brew", "install", *build_args, "--formula", *formula_names,
644+
ignore_failures:,
645+
env: { "HOMEBREW_DEVELOPER" => nil }
646+
@prepared_dependency_formulae.merge(formula_names) if steps.fetch(-1).passed?
647+
end
648+
649+
sig { params(dependent: Formula, testable_dependents: T::Array[Formula]).returns(T::Array[Dependency]) }
650+
def dependent_dependencies(dependent, testable_dependents)
651+
Dependency.expand(
652+
dependent,
653+
cache_key: "test-bot-dependent-dependencies-#{dependent.full_name}",
654+
) do |dep_dependent, dependency|
655+
next if !dependency.build? && !dependency.test? && !dependency.optional?
656+
next if dependency.test? &&
657+
dep_dependent == dependent &&
658+
!dependency.optional? &&
659+
testable_dependents.include?(dependent)
660+
661+
Dependency.prune
662+
end
663+
end
664+
665+
sig { params(formula: Formula).returns(T::Array[Formula]) }
666+
def recursive_dependency_formulae(formula)
667+
formula.recursive_dependencies.reject(&:optional?).map(&:to_formula)
668+
rescue TapFormulaUnavailableError => e
669+
raise if e.tap.installed?
670+
671+
e.tap.clear_cache
672+
safe_system "brew", "tap", e.tap.name
673+
retry
429674
end
430675

431676
sig { params(formula: Formula).void }

0 commit comments

Comments
 (0)