Skip to content

bundle: batch installs where possible - #23659

Merged
MikeMcQuaid merged 3 commits into
mainfrom
bundle-batch-formula-installs
Aug 26, 2026
Merged

bundle: batch installs where possible#23659
MikeMcQuaid merged 3 commits into
mainfrom
bundle-batch-formula-installs

Conversation

@dduugg

@dduugg dduugg commented Aug 25, 2026

Copy link
Copy Markdown
Member

What does this change do, and why?

Replaces brew bundle install's parallel-install scheduler with batching, as agreed in #23386. Supersedes and closes that PR.

brew bundle install ran one brew install --formula X child per Brewfile entry, several at a time. Each child's FormulaInstaller#lock takes an exclusive FormulaLock on the target rack and on every rack in its recursive dependency closure, whether or not those dependencies are already installed, and LockFile#lock is LOCK_EX | LOCK_NB so the loser dies immediately rather than waiting. Two entries collided whenever their closures intersected. ParallelInstaller avoided that by predicting the collisions before dispatching, which meant carrying a second, hand maintained copy of what lock does. #22293, #22837, #22899, #22948 and #23328 were all that copy drifting from the original, and nothing in the design signalled when it had drifted.

FormulaInstaller#lock opens with return unless self.class.locked.empty?, so one process installing N formulae never contends with itself. Batching removes the hazard rather than modelling it, so there is nothing left to keep in sync. It also replaces one DownloadQueue per child with the single queue brew install already uses, which is the second half of what you raised in #23386.

This is the same shape as #21252, which already collapses the fetch phase into one brew fetch a b c, one phase later. It is only viable because of #23525: before that, one failed download aborted the whole invocation before anything installed, which brew bundle's attempt-every-entry contract could not tolerate.

What is batched, and what is not

An entry is batched only if it is a formula, carries no option other than full_name or trusted, does not need a tap the Brewfile has not installed yet, and loads. Everything else keeps its own child process on the existing unmodified path: casks, taps, the extension types, and any entry with args:, link:, conflicts_with:, restart_service:, start_service:, postinstall: or version_file:.

That rule is what keeps the per-entry changed flag out of scope. Its only three consumers are restart_service, postinstall and the version_file branch, and every entry that can set those is excluded, so no batched entry ever reads it. install_formula! and upgrade_formula! are untouched for the entries that do.

Batched entries are split by whether they are already installed, giving at most one brew install --formula ... and one brew upgrade --formula .... Keeping brew upgrade for the outdated half preserves $HOMEBREW_BUNDLE_NO_UPGRADE semantics without touching the environment.

How per-entry success is decided

A successful batch means every entry in it succeeded, because brew install exits non-zero if any package failed. Only when the batch fails does the outcome fall back to asking what ended up installed, since the exit status cannot name the entry that failed. Each entry is then still finished individually so its link state is reconciled exactly as before.

Behaviour changes worth calling out

  • The tap phase, the sweep that installs taps entries live in but the Brewfile does not list, and the attestation gh pre-warm now run on every path. They previously lived inside ParallelInstaller#run! and so were skipped entirely for anyone on --jobs=1. That is a bug fix, and two existing specs were quietly relying on those phases never running.
  • Formula output arrives as a block: the Installing X / Using X lines print first, then the child's output. Same shape as the existing Fetching a, b, c phase.
  • Formulae are installed ahead of casks and extensions. Taps still go first.
  • --jobs is accepted but ignored, and warns when passed. $HOMEBREW_BUNDLE_JOBS and $HOMEBREW_BUNDLE_NO_JOBS are marked odeprecated.

Decision for you: whether --jobs should stay accepted-and-inert as here, or go straight to odisabled. I have kept it accepted so existing CI invocations do not hard-fail.

Known, unfixed, and separately tracked

  • install.rb's rescue BuildError; raise still aborts the remaining formulae in a batch, so a build-from-source failure loses the entries after it. Excluded entries are unaffected because they get their own invocation.
  • I found a lock leak in install_formulae while doing this, and it is worth its own PR: FormulaInstaller#unlock runs only from finish's ensure, which a raising install never reaches, so after the per-entry rescue added in Keep one failure from aborting a batch install #23525 self.class.locked is never cleared and every later formula in the batch skips taking its own rack locks. Demonstrated locally: after xz's installer locks, jq's lock is a no-op and @hold_locks stays false. I will open that separately unless you would rather it rode along here.
  • Batching cask entries, and reconstructing the changed flag so service and postinstall entries can join a batch, are both follow-ups.

Step-by-step reproduction

The bug this removes is #23328's, which reproduces with that issue's Dockerfile and Brewfile. On this branch the formula entries install in one child process, so no two of them can contend for a rack at all.


  • Have you followed our Contributing guidelines?
  • Have you checked for other open Pull Requests for the same change?
  • Have you explained what your changes do? Performance claims (e.g. "this is faster") must include Hyperfine benchmarks.
  • Have you explained why you'd like these changes included, not just what they do?
  • For bug fixes, have you given step-by-step brew commands to reproduce the bug?
  • Have you written new tests (excluding integration tests)? Here's an example.
  • Have you successfully run brew lgtm (style, typechecking and tests) locally?

  • I did not use AI/LLM to create this PR, or I disclosed the tool/model below and reviewed its output; I did not attribute commits to AI and will answer maintainer questions and review comments myself without AI/LLM.

AI was used. Claude Code (Claude Opus) was used to survey the dispatch path and the option-to-flag mapping, implement this, and rewrite the specs. Verification, all of which I ran and read myself:

  • brew lgtm clean (typecheck, style --changed --fix, tests --changed), plus bundle/installer, bundle/brew, cmd/bundle and env_config run explicitly since --changed does not reach every affected spec file.
  • Confirmed against the real code, not mocks, that FormulaInstaller#lock is process global and first wins, that lock is the first statement of #install while compute_dependencies runs later, and that unlock is reachable only from finish's ensure (which is the lock leak noted above, demonstrated with a scratch script driving two real FormulaInstallers).
  • Measured the contention this removes across 200 sampled Brewfiles of 5 to 30 entries drawn from the install-on-request analytics, plus this machine's own 86 explicitly-installed formulae: around half of all entry pairs share at least one rack, and that share is flat as the Brewfile grows.
  • Two of the design decisions here came from specs failing against the original plan, not from the plan being followed: the exit-status-first verdict (the state-only version conflated "needs work" with "ended up fine") and rescuing any load error in the batchability check rather than only FormulaUnavailableError.

I reviewed the full diff by hand before opening this.

@dduugg dduugg closed this Aug 25, 2026
@MikeMcQuaid MikeMcQuaid reopened this Aug 26, 2026
@MikeMcQuaid
MikeMcQuaid marked this pull request as ready for review August 26, 2026 17:48
Copilot AI lite review requested due to automatic review settings August 26, 2026 17:48

@MikeMcQuaid MikeMcQuaid left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dduugg Thanks! Extended the approach for other types that were batchable.

@MikeMcQuaid MikeMcQuaid changed the title bundle: batch formula installs into one brew install bundle: batch installs where possible Aug 26, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This pull request refactors brew bundle install to favor batching (delegating to fewer brew install invocations and package-manager-native batch installs) instead of maintaining a parallel-install scheduler that attempted to predict Homebrew’s internal lock behavior.

Changes:

  • Replace the bundle parallel installer/scheduler with batched brew install execution and per-entry finishing.
  • Deprecate and hide --jobs/HOMEBREW_BUNDLE_JOBS/HOMEBREW_BUNDLE_NO_JOBS, updating docs, completions, and env config accordingly.
  • Add “native batch” support for several bundle extensions (npm, cargo, mas, flatpak, VS Code) and update/expand specs.

Reviewed changes

Copilot reviewed 35 out of 35 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
manpages/brew.1 Removes --jobs and related env var documentation from the generated manpage.
docs/Manpage.md Removes --jobs and related env var documentation from docs.
completions/zsh/_brew Removes --jobs completion for bundle install/upgrade.
completions/fish/brew.fish Removes --jobs completion for bundle install/upgrade.
completions/bash/brew Removes --jobs completion for bundle install.
Library/Homebrew/test/support/helper/subcommand.rb Adjusts test arg helper behavior so jobs is nil unless explicitly set.
Library/Homebrew/test/install_spec.rb Adds coverage for continuing after a BuildError during formula batch installation.
Library/Homebrew/test/formula_installer_spec.rb Adds coverage ensuring locks are released when FormulaInstaller#install raises.
Library/Homebrew/test/env_config_spec.rb Removes tests for bundle_jobs behavior.
Library/Homebrew/test/cmd/bundle/check_subcommand_spec.rb Updates bundle check subcommand spec to match removed jobs context.
Library/Homebrew/test/cmd/bundle_spec.rb Removes env-driven jobs behavior spec.
Library/Homebrew/test/bundle/vscode_extension_spec.rb Adds/updates spec coverage for VS Code extension batch installs.
Library/Homebrew/test/bundle/npm_spec.rb Adds/updates spec coverage for npm batch installs.
Library/Homebrew/test/bundle/mac_app_store_spec.rb Adds/updates spec coverage for MAS batch installs.
Library/Homebrew/test/bundle/installer_spec.rb Reworks installer specs around batching and native batch delegation.
Library/Homebrew/test/bundle/flatpak_spec.rb Adds/updates spec coverage for Flatpak batch installs.
Library/Homebrew/test/bundle/cargo_spec.rb Adds/updates spec coverage for Cargo batch installs.
Library/Homebrew/install.rb Changes install_formulae to handle BuildError per-formula and continue.
Library/Homebrew/formula_installer.rb Ensures locks are released even when install exits via exceptions (incl. interrupts).
Library/Homebrew/env_config.rb Marks bundle jobs env vars as deprecated/ignored and removes custom parsing helper.
Library/Homebrew/bundle/subcommand/install.rb Hides --jobs and warns that it’s ignored when passed.
Library/Homebrew/bundle/subcommand/exec.rb Removes jobs from default bundle subcommand context for exec path.
Library/Homebrew/bundle/subcommand.rb Removes jobs/env-driven parallelism from bundle context computation.
Library/Homebrew/bundle/subcommand_context.rb Removes jobs from the typed subcommand context struct.
Library/Homebrew/bundle/parallel_installer.rb Deletes the parallel scheduler implementation.
Library/Homebrew/bundle/package_type.rb Introduces batch_installable?/install_batch! API for package types/extensions.
Library/Homebrew/bundle/installer.rb Implements Homebrew batching + native batch dispatch for extensions.
Library/Homebrew/bundle/extensions/vscode_extension.rb Adds native batch install for VS Code extensions.
Library/Homebrew/bundle/extensions/npm.rb Adds native batch install for npm packages.
Library/Homebrew/bundle/extensions/mac_app_store.rb Adds native batch install for MAS apps.
Library/Homebrew/bundle/extensions/flatpak.rb Adds native batch install for Flatpak refs (where safe).
Library/Homebrew/bundle/extensions/extension.rb Removes now-unused fetch hook from extension base class.
Library/Homebrew/bundle/extensions/cargo.rb Adds native batch install for Cargo crates.
Library/Homebrew/bundle/cask.rb Removes now-unused fetch hook from Cask package type.
Library/Homebrew/bundle/brew.rb Removes now-unused fetch/dependency helper methods tied to old scheduler/fetching.

💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.

Comment thread Library/Homebrew/bundle/installer.rb
dduugg and others added 3 commits August 26, 2026 19:20
`brew bundle install` ran one `brew install --formula X` child per Brewfile
entry, several at a time. Each child's `FormulaInstaller#lock` takes an
exclusive `FormulaLock` on the target rack and on every rack in its recursive
dependency closure, whether or not those dependencies are already installed,
and `LockFile#lock` is non-blocking, so the loser died immediately. Two entries
collided whenever their closures intersected, which is around half of all entry
pairs in the Brewfile samples measured. `ParallelInstaller` avoided that by
predicting the collisions, which meant carrying a second copy of what `lock`
does, and every bug in this area was that copy drifting from the original.

`FormulaInstaller#lock` returns early unless `self.class.locked` is empty, so
one process installing many formulae never contends with itself. Batching the
entries into a single `brew install` therefore removes the hazard rather than
modelling it, and leaves nothing to keep in sync. It also replaces one download
queue per child with the single queue `brew install` already uses.

Entries that pass extra arguments, need work before or after the install, or
are not formulae keep their own child process on the existing path, so nothing
that reads the per-entry changed flag joins a batch. The tap phase, the tap
sweep for entries that live in taps the Brewfile does not list, and the
attestation `gh` pre-warm move out of the scheduler and now run on every path
rather than only when more than one job was requested.

A successful batch means every entry in it succeeded, because `brew install`
exits non-zero if any package failed. When it fails, which entry failed cannot
be read from the exit status, so the outcome falls back to asking what ended up
installed.

`--jobs` is still accepted but ignored, and `$HOMEBREW_BUNDLE_JOBS` and
`$HOMEBREW_BUNDLE_NO_JOBS` are deprecated.
- Share one `brew install` and download queue across formulae and casks.
- Qualify cask arguments so formula name collisions cannot change type.
- Let compatible extensions use native multi-package installs.
- Continue after source build errors and release failed installer locks.
- Keep the inert `--jobs` flag compatible but hidden.
- Retry entries individually when a native batch raises or fails.
@MikeMcQuaid
MikeMcQuaid force-pushed the bundle-batch-formula-installs branch from 0cf598f to da2ba60 Compare August 26, 2026 18:20
@MikeMcQuaid
MikeMcQuaid enabled auto-merge August 26, 2026 18:41
@MikeMcQuaid
MikeMcQuaid added this pull request to the merge queue Aug 26, 2026
Merged via the queue into main with commit ca65d2a Aug 26, 2026
48 checks passed
@MikeMcQuaid
MikeMcQuaid deleted the bundle-batch-formula-installs branch August 26, 2026 18:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants