Skip to content

Commit

Permalink
Safely store concurrent compact index downloads
Browse files Browse the repository at this point in the history
When bundler is run concurrently using the same bundle dir in $HOME,
the versions file can be updated from two processes at once. The
download has been changed to a temporary file, which is securely moved
into place over the original.

If retrying the update operation, the original file is no longer
immediately deleted and instead a full download is performed, later
overwriting the original file if successful.

If two processes are updating in parallel, this should ensure the
original file isn't corrupted and that both processes succeed.

- Fixes rubygems#4519
  • Loading branch information
domcleal committed May 10, 2016
1 parent 8d3eb4a commit a0900c8
Show file tree
Hide file tree
Showing 3 changed files with 81 additions and 22 deletions.
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# frozen_string_literal: true
require "fileutils"
require "stringio"
require "tmpdir"
require "zlib"

class Bundler::CompactIndexClient
Expand All @@ -24,33 +26,41 @@ def initialize(fetcher)
def update(local_path, remote_path, retrying = nil)
headers = {}

if local_path.file?
headers["If-None-Match"] = etag_for(local_path)
headers["Range"] = "bytes=#{local_path.size}-"
else
# Fastly ignores Range when Accept-Encoding: gzip is set
headers["Accept-Encoding"] = "gzip"
end
Dir.mktmpdir(local_path.basename.to_s, local_path.dirname) do |local_temp_dir|
local_temp_path = Pathname.new(local_temp_dir).join(local_path.basename)

response = @fetcher.call(remote_path, headers)
return if response.is_a?(Net::HTTPNotModified)
# download new file if retrying
if retrying.nil? && local_path.file?
FileUtils.cp local_path, local_temp_path
headers["If-None-Match"] = etag_for(local_temp_path)
headers["Range"] = "bytes=#{local_temp_path.size}-"
else
# Fastly ignores Range when Accept-Encoding: gzip is set
headers["Accept-Encoding"] = "gzip"
end

content = response.body
if response["Content-Encoding"] == "gzip"
content = Zlib::GzipReader.new(StringIO.new(content)).read
end
response = @fetcher.call(remote_path, headers)
return if response.is_a?(Net::HTTPNotModified)

content = response.body
if response["Content-Encoding"] == "gzip"
content = Zlib::GzipReader.new(StringIO.new(content)).read
end

mode = response.is_a?(Net::HTTPPartialContent) ? "a" : "w"
local_path.open(mode) {|f| f << content }
mode = response.is_a?(Net::HTTPPartialContent) ? "a" : "w"
local_temp_path.open(mode) {|f| f << content }

response_etag = response["ETag"]
return if etag_for(local_path) == response_etag
response_etag = response["ETag"]
if etag_for(local_temp_path) == response_etag
FileUtils.mv(local_temp_path, local_path)
return
end

if retrying.nil?
local_path.delete
update(local_path, remote_path, :retrying)
else
raise MisMatchedChecksumError.new(remote_path, response_etag, etag_for(local_path))
if retrying.nil?
update(local_path, remote_path, :retrying)
else
raise MisMatchedChecksumError.new(remote_path, response_etag, etag_for(local_temp_path))
end
end
end

Expand Down
18 changes: 18 additions & 0 deletions spec/install/gems/compact_index_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -656,4 +656,22 @@ def start
bundle! :install, :artifice => "compact_index_forbidden"
end
end

it "performs partial update while local cache is updated by another process" do
gemfile <<-G
source "#{source_uri}"
gem 'rack'
G

# Create an empty file to trigger a partial download
versions = File.join(Bundler.rubygems.user_home, ".bundle", "cache", "compact_index",
"localgemserver.test.80.dd34752a738ee965a2a4298dc16db6c5", "versions")
FileUtils.mkdir_p(File.dirname(versions))
FileUtils.touch(versions)

bundle! :install, :artifice => "compact_index_concurrent_download"

expect(File.read(versions)).to start_with('created_at')
should_be_installed "rack 1.0.0"
end
end
31 changes: 31 additions & 0 deletions spec/support/artifice/compact_index_concurrent_download.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# frozen_string_literal: true
require File.expand_path("../compact_index", __FILE__)

Artifice.deactivate

class CompactIndexConcurrentDownload < CompactIndexAPI
get "/versions" do
versions = File.join(Bundler.rubygems.user_home, ".bundle", "cache", "compact_index",
"localgemserver.test.80.dd34752a738ee965a2a4298dc16db6c5", "versions")

# Verify the original (empty) content hasn't been deleted, e.g. on a retry
File.read(versions) == '' or fail('Original file should be present and empty')

# Verify this is only requested once for a partial download
env['HTTP_RANGE'] or fail('Missing Range header for expected partial download')

# Overwrite the file in parallel, which should be then overwritten
# after a successful download to prevent corruption
File.open(versions, "w") { |f| f.puts "another process" }

etag_response do
file = tmp("versions.list")
file.delete if file.file?
file = CompactIndex::VersionsFile.new(file.to_s)
file.update_with(gems)
CompactIndex.versions(file, nil, {})
end
end
end

Artifice.activate_with(CompactIndexConcurrentDownload)

0 comments on commit a0900c8

Please sign in to comment.