summaryrefslogtreecommitdiff
path: root/lib/bundler/installer/parallel_installer.rb
blob: 020db30b8443b53f5478d6829db8e462b899292d (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
# frozen_string_literal: true

require_relative "../worker"
require_relative "gem_installer"

module Bundler
  class ParallelInstaller
    class SpecInstallation
      attr_accessor :spec, :name, :full_name, :post_install_message, :state, :error
      def initialize(spec)
        @spec = spec
        @name = spec.name
        @full_name = spec.full_name
        @state = :none
        @post_install_message = ""
        @error = nil
      end

      def installed?
        state == :installed
      end

      def enqueued?
        state == :enqueued
      end

      def enqueue_with_priority?
        state == :installable && spec.extensions.any?
      end

      def failed?
        state == :failed
      end

      def ready_to_enqueue?
        state == :none
      end

      def ready_to_install?(installed_specs)
        return false unless state == :downloaded

        spec.extensions.none? || dependencies_installed?(installed_specs)
      end

      def has_post_install_message?
        !post_install_message.empty?
      end

      def ignorable_dependency?(dep)
        dep.type == :development || dep.name == @name
      end

      # Checks installed dependencies against spec's dependencies to make
      # sure needed dependencies have been installed.
      def dependencies_installed?(installed_specs)
        dependencies.all? {|d| installed_specs.include? d.name }
      end

      # Represents only the non-development dependencies, the ones that are
      # itself and are in the total list.
      def dependencies
        @dependencies ||= all_dependencies.reject {|dep| ignorable_dependency? dep }
      end

      # Represents all dependencies
      def all_dependencies
        @spec.dependencies
      end

      def to_s
        "#<#{self.class} #{full_name} (#{state})>"
      end
    end

    def self.call(*args, **kwargs)
      new(*args, **kwargs).call
    end

    attr_reader :size

    def initialize(installer, all_specs, size, standalone, force, local: false, skip: nil)
      @installer = installer
      @size = size
      @standalone = standalone
      @force = force
      @local = local
      @specs = all_specs.map {|s| SpecInstallation.new(s) }
      @specs.each do |spec_install|
        spec_install.state = :installed if skip.include?(spec_install.name)
      end if skip
      @spec_set = all_specs
      @rake = @specs.find {|s| s.name == "rake" unless s.installed? }
    end

    def call
      if @rake
        do_download(@rake, 0)
        do_install(@rake, 0)
        Gem::Specification.reset
      end

      if @size > 1
        install_with_worker
      else
        install_serially
      end

      handle_error if failed_specs.any?
      @specs
    ensure
      worker_pool&.stop
    end

    private

    def failed_specs
      @specs.select(&:failed?)
    end

    def install_with_worker
      installed_specs = {}
      enqueue_specs(installed_specs)

      process_specs(installed_specs) until finished_installing?
    end

    def install_serially
      until finished_installing?
        raise "failed to find a spec to enqueue while installing serially" unless spec_install = @specs.find(&:ready_to_enqueue?)
        spec_install.state = :enqueued
        do_download(spec_install, 0)
        do_install(spec_install, 0)
      end
    end

    def worker_pool
      @worker_pool ||= Bundler::Worker.new @size, "Parallel Installer", lambda {|spec_install, worker_num|
        case spec_install.state
        when :enqueued
          do_download(spec_install, worker_num)
        when :installable
          do_install(spec_install, worker_num)
        else
          spec_install
        end
      }
    end

    def do_download(spec_install, worker_num)
      Plugin.hook(Plugin::Events::GEM_BEFORE_INSTALL, spec_install)

      gem_installer = Bundler::GemInstaller.new(
        spec_install.spec, @installer, @standalone, worker_num, @force, @local
      )

      success, message = gem_installer.download

      if success
        spec_install.state = :downloaded
      else
        spec_install.error = "#{message}\n\n#{require_tree_for_spec(spec_install.spec)}"
        spec_install.state = :failed
      end

      spec_install
    end

    def do_install(spec_install, worker_num)
      gem_installer = Bundler::GemInstaller.new(
        spec_install.spec, @installer, @standalone, worker_num, @force, @local
      )
      success, message = gem_installer.install_from_spec
      if success
        spec_install.state = :installed
        spec_install.post_install_message = message unless message.nil?
      else
        spec_install.error = "#{message}\n\n#{require_tree_for_spec(spec_install.spec)}"
        spec_install.state = :failed
      end
      Plugin.hook(Plugin::Events::GEM_AFTER_INSTALL, spec_install)
      spec_install
    end

    # Dequeue a spec and save its post-install message and then enqueue the
    # remaining specs.
    # Some specs might've had to wait til this spec was installed to be
    # processed so the call to `enqueue_specs` is important after every
    # dequeue.
    def process_specs(installed_specs)
      spec = worker_pool.deq

      if spec.installed?
        installed_specs[spec.name] = true
        return
      elsif spec.failed?
        return
      elsif spec.ready_to_install?(installed_specs)
        spec.state = :installable
      end

      worker_pool.enq(spec, priority: spec.enqueue_with_priority?)
    end

    def finished_installing?
      @specs.all? do |spec|
        return true if spec.failed?
        spec.installed?
      end
    end

    def handle_error
      errors = failed_specs.map(&:error)
      if exception = errors.find {|e| e.is_a?(Bundler::BundlerError) }
        raise exception
      end
      raise Bundler::InstallError, errors.join("\n\n")
    end

    def require_tree_for_spec(spec)
      tree = @spec_set.what_required(spec)
      t = String.new("In #{File.basename(SharedHelpers.default_gemfile)}:\n")
      tree.each_with_index do |s, depth|
        t << "  " * depth.succ << s.name
        unless tree.last == s
          t << %( was resolved to #{s.version}, which depends on)
        end
        t << %(\n)
      end
      t
    end

    # Keys in the remains hash represent uninstalled gems specs.
    # We enqueue all gem specs that do not have any dependencies.
    # Later we call this lambda again to install specs that depended on
    # previously installed specifications. We continue until all specs
    # are installed.
    def enqueue_specs(installed_specs)
      @specs.each do |spec|
        if spec.installed?
          installed_specs[spec.name] = true
          next
        end

        spec.state = :enqueued
        worker_pool.enq spec
      end
    end
  end
end